Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d80d4d0
wip(objectql): post-hook declared-field door on insert and update (#1…
claude Aug 31, 2026
558dabc
test(objectql): pin the post-hook declared-field door across three dr…
claude Aug 31, 2026
41f9f1e
test(objectql): tighten three fixtures whose hooks wrote fields their…
claude Aug 31, 2026
60aa03e
test(runtime): repoint the L2 body driver-split pin at the convergenc…
claude Aug 31, 2026
7599391
docs,lint: the L2 body undeclared-key answer is one envelope on every…
claude Aug 31, 2026
b44a658
chore: changeset for the post-hook declared-field door (#13657)
claude Aug 31, 2026
4a9731b
Merge remote-tracking branch 'origin/main' into claude/issue-13657-po…
claude Aug 31, 2026
b5fd717
lint: keep the tracker id out of the runtime message string (#13657)
claude Aug 31, 2026
30c02e7
test(objectql): drop an unused parameter from the driver double's ref…
claude Aug 31, 2026
66d1cf4
chore(gates): ratchet the query-options-erasure test surface DOWN, 24…
claude Aug 31, 2026
bc559ba
Merge remote-tracking branch 'origin/main' into claude/issue-13657-po…
claude Aug 31, 2026
901c2fa
docs(permissions): re-anchor the system-context census after this bra…
claude Aug 31, 2026
3ca55cb
Merge remote-tracking branch 'origin/main' into claude/issue-13657-me…
claude Sep 1, 2026
3e65b6a
docs(permissions): re-derive the system-context census anchors after …
claude Sep 1, 2026
e9daefb
Merge remote-tracking branch 'origin/main' into claude/issue-13657-me…
claude Sep 1, 2026
1db3d32
docs(permissions): re-derive the census anchors after the second main…
claude Sep 1, 2026
fb8232a
chore(changeset): regrade objectql to minor with the argued BREAKING …
claude Sep 1, 2026
42b8e26
Merge remote-tracking branch 'origin/main' into patch/13864-sync
claude Sep 1, 2026
9af92aa
docs(permissions): re-derive the census anchors from the merged tree
claude Sep 1, 2026
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
61 changes: 61 additions & 0 deletions .changeset/post-hook-undeclared-field-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
'@objectstack/objectql': minor
'@objectstack/lint': patch
---

Refuse an undeclared field a `before*` hook writes, identically on every driver

**BREAKING** accept-set narrowing at the post-hook write door, shipped as `minor`
under the repo's launch-window convention for breaking changes.

**Bump level, argued**: `@objectstack/objectql` is `minor`, not `patch`. A
`before*` hook or an L2 (`language:'js'`) body writing a key the object never
declares **used to succeed** on the `memory` family — the value reached the
store and persisted as a shadow column — and now **throws**, `INVALID_FIELD` /
**400**, on every driver. That is a narrowing of the accept set on the record
payload, a surface every hook body touches; it is not an instrument or a message
fix, and a hook that relied on either driver-dependent outcome stops working at
run time. The same-package sibling `.changeset/hook-input-symbol-key-refusal.md`
argues exactly this shape — "used to succeed, and now throw. That is a narrowing
of the accept set" — to `minor`, and the launch-window convention is what keeps
it off `major` (pre-1.0 lockstep semantics: a breaking change does not burn a
major version while the stack versions in lockstep — see
`scripts/check-changeset-no-major.mjs`). `patch` would under-declare a change
that turns a passing hook into a throwing one.

`'@objectstack/lint': patch` is deliberate and stays. That half of the diff is
message and comment prose only: `validateHookBodyWrites` reports the same
findings on the same bodies at the same severity, with wording that now names
the runtime refusal instead of the driver split this change retires.

The declared-field door (#8682 on insert, #8738 on update) runs before the
`before*` hooks — deliberately, so a payload about to be refused never consumes
an autonumber (#8737). That left the payload the hooks themselves produce
unjudged: a key a `beforeInsert` / `beforeUpdate` hook or an L2 (`language:'js'`)
body wrote went straight to the driver, and the drivers disagreed. `memory`
accepted it and stored a shadow column; `driver-sql` threw a raw `SQLITE_ERROR`
with no `status` and the bound statement and its values quoted back in the
message; `sqlite-wasm` threw a bare `Error` with neither. One app and one hook
meant different things on two deployments, and nothing in the app could tell
which one it was running on.

The same check now runs a second time over the post-hook payload, before any
statement is built, so a hook-written undeclared key is refused with the caller
path's envelope — `INVALID_FIELD` / **400**, `Unknown field 'x' on object 'y'` —
on every driver, because none of them is reached. The existing pre-hook door is
unchanged and stays exactly where it is.

This is a security fix as well as a consistency one: `fieldPermissions` is keyed
by declared field name and reports only fields explicitly marked non-editable, so
a key the object never declares can carry no entry and could never be gated by
field-level security. On `memory`-family stores such a value was persisted where
no view, formula, index or permission could name it.

The platform's own stamps are unaffected. `created_at` / `updated_at` — the two
the built-in audit hook writes unconditionally, because SQL drivers create them
as built-in columns on every table — are already tolerated by this check
alongside `id`; every other stamp (`created_by`, `updated_by`, `tenant_id`) is
guarded by an explicit declaration test in the hook that writes it.

<!-- adr-0087: not-required (no-migration-prescription) No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed. This narrows which run-time record payload the engine accepts after the `before*` hooks have run; an undeclared key was never a declarable metadata surface, so `objectstack migrate meta` has nothing in a stored source to rewrite. The remedy for an affected hook body is to declare the field on the object or stop writing it, which is authoring guidance, not a mechanical rewrite of stored metadata. -->

13 changes: 8 additions & 5 deletions content/docs/automation/hook-bodies.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,19 +204,22 @@ A structured `writes` declaration was considered and dropped ([#3700](https://gi

#### What still happens at runtime

An unknown field is **not** caught at runtime, and it does not fail quietly either. The write-path validator walks the object's *declared* fields, so an undeclared key is neither rejected nor stripped, and the sandbox's mutations are copied back onto the payload verbatim. What happens next is the driver's call:
An unknown field **is** caught at runtime, and the answer is the same on every driver. The sandbox's mutations are copied back onto the payload verbatim (`applyMutationsToInput` is a plain `Object.assign`) and the write-path validator still walks only the object's *declared* fields — but since [#13657](https://github.com/objectstack-ai/objectstack/issues/13657) the declared-field door runs a **second** time, over the payload the `before*` hooks produced, before any statement is built:

- **SQL drivers** put the stray column into the statement, so the **whole write fails** with a driver-level error (`table deal has no column named stagee`) — nothing is stored, and the error surfaces far from the authoring mistake.
- **Schemaless drivers** (memory, MongoDB) silently persist the stray key alongside the real ones.
```
INVALID_FIELD / 400 / Unknown field 'stagee' on object 'deal'
```

Identical on `memory`, `driver-sql` and `sqlite-wasm`, because none of them is reached. Before #13657 the driver decided instead, and the two families disagreed — SQL failed the whole write with an untyped `SQLITE_ERROR`, while schemaless drivers silently persisted the stray key as a column nothing downstream reads (and which field-level security, keyed by *declared* field name, could never gate). One app, one body, two meanings decided by which driver a deployment happened to run.

Neither outcome is the one you wanted, and the advisory warning is the earliest signal you get.
The runtime refusal is now the backstop; the advisory warning is still the earliest signal you get, and the one that names the mistake where it was made.

Because the existence check is advisory, and every write-side check here is literal-only:

- **Treat `hook-body-write-unknown-field` as a build failure by convention.** It does not gate, but the rule is tuned for near-zero false positives — in practice a warning is a real typo.
- **Check by hand what the parser cannot see.** Computed keys, spreads, aliased input and dynamic object names are invisible to the rule; for an array or `"*"` hook, every field must exist on every target.
- **Prefer a flow `update_record` node when the write set is fixed — and for *this* check most of all.** A flow node's writes are structured config: they diff field-by-field, render in the Console designer, and since [#4271](https://github.com/objectstack-ai/objectstack/issues/4271) the field-existence check gates there too — `flow-node-write-unknown-field` is an **error**, not the advisory warning a body gets, because a node's `fields` is a literal map next to a literal `objectName`: there is no parser in between that could have mis-extracted it, so a finding is a certainty rather than a best effort. (The *writability* check now has a hook-side counterpart — see [Writing a `readonly` field](#writing-a-readonly-field) below — but it covers only the `ctx.api` channel.)
- **Exercise the hook against a real object before shipping** — on SQL drivers the mistake surfaces on the first write; schemaless drivers won't tell you.
- **Exercise the hook against a real object before shipping** — the mistake surfaces on the first write, identically on every driver.

### Signature conventions

Expand Down
16 changes: 8 additions & 8 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,18 +109,18 @@ that silently does not happen.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10787` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10949` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9680` |
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10882` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11044` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9747` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1737` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9717`, `readonly-strict-errors.ts:66` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9784`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5705` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3574`, `:3584`, `:3611` |
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` |
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6403` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11535` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11464` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11630` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11559` |

### 3. Sharing (`plugin-sharing`)

Expand DownExpand Up@@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them.
| # | Site | Package | What it does |
|:--|:---|:---|:---|
| 62 | `objectql/src/engine.ts:3406` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
| 63 | `objectql/src/engine.ts:13876` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 63 | `objectql/src/engine.ts:13971` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report |
| 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |

Expand All@@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs.
|:---|:---|:---|
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` |
| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9663`–`9680` |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9730`–`9747` |
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
Expand Down
41 changes: 25 additions & 16 deletions packages/lint/src/validate-hook-body-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,24 +4,30 @@
//
// An L2 body that writes a field the target object never declares —
// `ctx.input.amout = 0`, `ctx.api.object('deal').update({ stag: 'won' })` —
// runs clean in the QuickJS sandbox and reaches the driver UNFILTERED:
// runs clean in the QuickJS sandbox and reaches the write path UNFILTERED:
// `applyMutationsToInput` (runtime/src/sandbox/body-runner.ts) is a plain
// `Object.assign`, and `validateRecord` walks declared fields on insert and
// `continue`s past a key with no field def on update. What happens after that
// is DRIVER-DEPENDENT, and neither half is acceptable:
// `continue`s past a key with no field def on update.
//
// • SQL — the stray column enters the knex statement and the WHOLE write
// fails with a driver-level error (`table deal has no column named
// stagee`). The write is lost, and the error surfaces far from the
// authoring mistake that caused it.
// • Schemaless (memory, MongoDB) — the driver spreads the payload, so the
// stray key IS persisted: an undeclared column nothing downstream reads.
// [#13657] What happens after that used to be DRIVER-DEPENDENT, and neither
// half was acceptable — SQL failed the whole write with an untyped
// `SQLITE_ERROR` far from the authoring mistake, while schemaless drivers
// (memory, MongoDB) spread the payload and PERSISTED the stray key as a column
// nothing downstream reads. #13657 closed that: the declared-field door now
// runs a second time over the payload the `before*` hooks produced, so the key
// is refused `INVALID_FIELD` / 400 identically on every driver, before any
// statement is built.
//
// Either way the mistake is invisible where it is MADE — the #4001 family, if
// not literally its silent-no-op shape. Both runtime outcomes are pinned by
// ⚠️ That does NOT retire this rule — it changes what it is worth. The runtime
// refusal arrives at WRITE time, on whichever record first exercises the
// branch; this rule arrives at AUTHOR time and names the field, the object and
// the body. The mistake is still invisible where it is MADE, which is the
// #4001 family and the whole reason for a build-time check.
//
// The runtime answer is pinned by
// `runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts`
// so this rule's wording cannot drift from what the runtime does; the same
// split is documented in `content/docs/automation/hook-bodies.mdx`.
// so this rule's wording cannot drift from what the runtime does; the same is
// documented in `content/docs/automation/hook-bodies.mdx`.
//
// The read side (`hook.condition`, ADR-0032) and the capability surface are
// statically checked; until this rule, the write side was the one blind face
Expand DownExpand Up@@ -817,9 +823,12 @@ export function validateHookBodyWrites(stack: AnyRec): HookBodyWriteFinding[] {
path,
message:
`body writes '${w.field}' to its input, but ${objDesc} ${declares}. The sandboxed script runs ` +
`clean and the value is copied back onto the record payload unfiltered — on a SQL driver the ` +
`stray column then fails the WHOLE write with a driver-level error far from here; on a ` +
`schemaless driver (memory, MongoDB) it is persisted as an undeclared key (#4271).`,
// The post-hook declared-field door (#13657) is what refuses it; the
// id stays in this comment rather than in the string, which reaches
// authors and operators who cannot resolve a tracker number.
`clean and the value is copied back onto the record payload unfiltered, so the write is then ` +
`REFUSED at run time — INVALID_FIELD / 400, identically on every driver (#4271). The ` +
`record is never written, and the refusal names the field far from the body that wrote it.`,
hint: fixHint(w.field, unionCandidates(targetSets)),
});
} else {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Refuse an undeclared field a before-hook writes — the post-hook half of the declared-field door, one envelope on every driver by claude[bot] · Pull Request #13864 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d80d4d0
wip(objectql): post-hook declared-field door on insert and update (#1…
claude Aug 31, 2026
558dabc
test(objectql): pin the post-hook declared-field door across three dr…
claude Aug 31, 2026
41f9f1e
test(objectql): tighten three fixtures whose hooks wrote fields their…
claude Aug 31, 2026
60aa03e
test(runtime): repoint the L2 body driver-split pin at the convergenc…
claude Aug 31, 2026
7599391
docs,lint: the L2 body undeclared-key answer is one envelope on every…
claude Aug 31, 2026
b44a658
chore: changeset for the post-hook declared-field door (#13657)
claude Aug 31, 2026
4a9731b
Merge remote-tracking branch 'origin/main' into claude/issue-13657-po…
claude Aug 31, 2026
b5fd717
lint: keep the tracker id out of the runtime message string (#13657)
claude Aug 31, 2026
30c02e7
test(objectql): drop an unused parameter from the driver double's ref…
claude Aug 31, 2026
66d1cf4
chore(gates): ratchet the query-options-erasure test surface DOWN, 24…
claude Aug 31, 2026
bc559ba
Merge remote-tracking branch 'origin/main' into claude/issue-13657-po…
claude Aug 31, 2026
901c2fa
docs(permissions): re-anchor the system-context census after this bra…
claude Aug 31, 2026
3ca55cb
Merge remote-tracking branch 'origin/main' into claude/issue-13657-me…
claude Sep 1, 2026
3e65b6a
docs(permissions): re-derive the system-context census anchors after …
claude Sep 1, 2026
e9daefb
Merge remote-tracking branch 'origin/main' into claude/issue-13657-me…
claude Sep 1, 2026
1db3d32
docs(permissions): re-derive the census anchors after the second main…
claude Sep 1, 2026
fb8232a
chore(changeset): regrade objectql to minor with the argued BREAKING …
claude Sep 1, 2026
42b8e26
Merge remote-tracking branch 'origin/main' into patch/13864-sync
claude Sep 1, 2026
9af92aa
docs(permissions): re-derive the census anchors from the merged tree
claude Sep 1, 2026
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
61 changes: 61 additions & 0 deletions .changeset/post-hook-undeclared-field-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
'@objectstack/objectql': minor
'@objectstack/lint': patch
---

Refuse an undeclared field a `before*` hook writes, identically on every driver

**BREAKING** accept-set narrowing at the post-hook write door, shipped as `minor`
under the repo's launch-window convention for breaking changes.

**Bump level, argued**: `@objectstack/objectql` is `minor`, not `patch`. A
`before*` hook or an L2 (`language:'js'`) body writing a key the object never
declares **used to succeed** on the `memory` family — the value reached the
store and persisted as a shadow column — and now **throws**, `INVALID_FIELD` /
**400**, on every driver. That is a narrowing of the accept set on the record
payload, a surface every hook body touches; it is not an instrument or a message
fix, and a hook that relied on either driver-dependent outcome stops working at
run time. The same-package sibling `.changeset/hook-input-symbol-key-refusal.md`
argues exactly this shape — "used to succeed, and now throw. That is a narrowing
of the accept set" — to `minor`, and the launch-window convention is what keeps
it off `major` (pre-1.0 lockstep semantics: a breaking change does not burn a
major version while the stack versions in lockstep — see
`scripts/check-changeset-no-major.mjs`). `patch` would under-declare a change
that turns a passing hook into a throwing one.

`'@objectstack/lint': patch` is deliberate and stays. That half of the diff is
message and comment prose only: `validateHookBodyWrites` reports the same
findings on the same bodies at the same severity, with wording that now names
the runtime refusal instead of the driver split this change retires.

The declared-field door (#8682 on insert, #8738 on update) runs before the
`before*` hooks — deliberately, so a payload about to be refused never consumes
an autonumber (#8737). That left the payload the hooks themselves produce
unjudged: a key a `beforeInsert` / `beforeUpdate` hook or an L2 (`language:'js'`)
body wrote went straight to the driver, and the drivers disagreed. `memory`
accepted it and stored a shadow column; `driver-sql` threw a raw `SQLITE_ERROR`
with no `status` and the bound statement and its values quoted back in the
message; `sqlite-wasm` threw a bare `Error` with neither. One app and one hook
meant different things on two deployments, and nothing in the app could tell
which one it was running on.

The same check now runs a second time over the post-hook payload, before any
statement is built, so a hook-written undeclared key is refused with the caller
path's envelope — `INVALID_FIELD` / **400**, `Unknown field 'x' on object 'y'` —
on every driver, because none of them is reached. The existing pre-hook door is
unchanged and stays exactly where it is.

This is a security fix as well as a consistency one: `fieldPermissions` is keyed
by declared field name and reports only fields explicitly marked non-editable, so
a key the object never declares can carry no entry and could never be gated by
field-level security. On `memory`-family stores such a value was persisted where
no view, formula, index or permission could name it.

The platform's own stamps are unaffected. `created_at` / `updated_at` — the two
the built-in audit hook writes unconditionally, because SQL drivers create them
as built-in columns on every table — are already tolerated by this check
alongside `id`; every other stamp (`created_by`, `updated_by`, `tenant_id`) is
guarded by an explicit declaration test in the hook that writes it.

<!-- adr-0087: not-required (no-migration-prescription) No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed. This narrows which run-time record payload the engine accepts after the `before*` hooks have run; an undeclared key was never a declarable metadata surface, so `objectstack migrate meta` has nothing in a stored source to rewrite. The remedy for an affected hook body is to declare the field on the object or stop writing it, which is authoring guidance, not a mechanical rewrite of stored metadata. -->

13 changes: 8 additions & 5 deletions content/docs/automation/hook-bodies.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,19 +204,22 @@ A structured `writes` declaration was considered and dropped ([#3700](https://gi

#### What still happens at runtime

An unknown field is **not** caught at runtime, and it does not fail quietly either. The write-path validator walks the object's *declared* fields, so an undeclared key is neither rejected nor stripped, and the sandbox's mutations are copied back onto the payload verbatim. What happens next is the driver's call:
An unknown field **is** caught at runtime, and the answer is the same on every driver. The sandbox's mutations are copied back onto the payload verbatim (`applyMutationsToInput` is a plain `Object.assign`) and the write-path validator still walks only the object's *declared* fields — but since [#13657](https://github.com/objectstack-ai/objectstack/issues/13657) the declared-field door runs a **second** time, over the payload the `before*` hooks produced, before any statement is built:

- **SQL drivers** put the stray column into the statement, so the **whole write fails** with a driver-level error (`table deal has no column named stagee`) — nothing is stored, and the error surfaces far from the authoring mistake.
- **Schemaless drivers** (memory, MongoDB) silently persist the stray key alongside the real ones.
```
INVALID_FIELD / 400 / Unknown field 'stagee' on object 'deal'
```

Identical on `memory`, `driver-sql` and `sqlite-wasm`, because none of them is reached. Before #13657 the driver decided instead, and the two families disagreed — SQL failed the whole write with an untyped `SQLITE_ERROR`, while schemaless drivers silently persisted the stray key as a column nothing downstream reads (and which field-level security, keyed by *declared* field name, could never gate). One app, one body, two meanings decided by which driver a deployment happened to run.

Neither outcome is the one you wanted, and the advisory warning is the earliest signal you get.
The runtime refusal is now the backstop; the advisory warning is still the earliest signal you get, and the one that names the mistake where it was made.

Because the existence check is advisory, and every write-side check here is literal-only:

- **Treat `hook-body-write-unknown-field` as a build failure by convention.** It does not gate, but the rule is tuned for near-zero false positives — in practice a warning is a real typo.
- **Check by hand what the parser cannot see.** Computed keys, spreads, aliased input and dynamic object names are invisible to the rule; for an array or `"*"` hook, every field must exist on every target.
- **Prefer a flow `update_record` node when the write set is fixed — and for *this* check most of all.** A flow node's writes are structured config: they diff field-by-field, render in the Console designer, and since [#4271](https://github.com/objectstack-ai/objectstack/issues/4271) the field-existence check gates there too — `flow-node-write-unknown-field` is an **error**, not the advisory warning a body gets, because a node's `fields` is a literal map next to a literal `objectName`: there is no parser in between that could have mis-extracted it, so a finding is a certainty rather than a best effort. (The *writability* check now has a hook-side counterpart — see [Writing a `readonly` field](#writing-a-readonly-field) below — but it covers only the `ctx.api` channel.)
- **Exercise the hook against a real object before shipping** — on SQL drivers the mistake surfaces on the first write; schemaless drivers won't tell you.
- **Exercise the hook against a real object before shipping** — the mistake surfaces on the first write, identically on every driver.

### Signature conventions

Expand Down
16 changes: 8 additions & 8 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,18 +109,18 @@ that silently does not happen.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10787` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10949` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9680` |
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10882` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11044` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9747` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1737` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9717`, `readonly-strict-errors.ts:66` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9784`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5705` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3574`, `:3584`, `:3611` |
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` |
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6403` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11535` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11464` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11630` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11559` |

### 3. Sharing (`plugin-sharing`)

Expand DownExpand Up@@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them.
| # | Site | Package | What it does |
|:--|:---|:---|:---|
| 62 | `objectql/src/engine.ts:3406` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
| 63 | `objectql/src/engine.ts:13876` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 63 | `objectql/src/engine.ts:13971` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report |
| 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |

Expand All@@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs.
|:---|:---|:---|
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` |
| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9663`–`9680` |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9730`–`9747` |
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
Expand Down
41 changes: 25 additions & 16 deletions packages/lint/src/validate-hook-body-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,24 +4,30 @@
//
// An L2 body that writes a field the target object never declares —
// `ctx.input.amout = 0`, `ctx.api.object('deal').update({ stag: 'won' })` —
// runs clean in the QuickJS sandbox and reaches the driver UNFILTERED:
// runs clean in the QuickJS sandbox and reaches the write path UNFILTERED:
// `applyMutationsToInput` (runtime/src/sandbox/body-runner.ts) is a plain
// `Object.assign`, and `validateRecord` walks declared fields on insert and
// `continue`s past a key with no field def on update. What happens after that
// is DRIVER-DEPENDENT, and neither half is acceptable:
// `continue`s past a key with no field def on update.
//
// • SQL — the stray column enters the knex statement and the WHOLE write
// fails with a driver-level error (`table deal has no column named
// stagee`). The write is lost, and the error surfaces far from the
// authoring mistake that caused it.
// • Schemaless (memory, MongoDB) — the driver spreads the payload, so the
// stray key IS persisted: an undeclared column nothing downstream reads.
// [#13657] What happens after that used to be DRIVER-DEPENDENT, and neither
// half was acceptable — SQL failed the whole write with an untyped
// `SQLITE_ERROR` far from the authoring mistake, while schemaless drivers
// (memory, MongoDB) spread the payload and PERSISTED the stray key as a column
// nothing downstream reads. #13657 closed that: the declared-field door now
// runs a second time over the payload the `before*` hooks produced, so the key
// is refused `INVALID_FIELD` / 400 identically on every driver, before any
// statement is built.
//
// Either way the mistake is invisible where it is MADE — the #4001 family, if
// not literally its silent-no-op shape. Both runtime outcomes are pinned by
// ⚠️ That does NOT retire this rule — it changes what it is worth. The runtime
// refusal arrives at WRITE time, on whichever record first exercises the
// branch; this rule arrives at AUTHOR time and names the field, the object and
// the body. The mistake is still invisible where it is MADE, which is the
// #4001 family and the whole reason for a build-time check.
//
// The runtime answer is pinned by
// `runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts`
// so this rule's wording cannot drift from what the runtime does; the same
// split is documented in `content/docs/automation/hook-bodies.mdx`.
// so this rule's wording cannot drift from what the runtime does; the same is
// documented in `content/docs/automation/hook-bodies.mdx`.
//
// The read side (`hook.condition`, ADR-0032) and the capability surface are
// statically checked; until this rule, the write side was the one blind face
Expand DownExpand Up@@ -817,9 +823,12 @@ export function validateHookBodyWrites(stack: AnyRec): HookBodyWriteFinding[] {
path,
message:
`body writes '${w.field}' to its input, but ${objDesc} ${declares}. The sandboxed script runs ` +
`clean and the value is copied back onto the record payload unfiltered — on a SQL driver the ` +
`stray column then fails the WHOLE write with a driver-level error far from here; on a ` +
`schemaless driver (memory, MongoDB) it is persisted as an undeclared key (#4271).`,
// The post-hook declared-field door (#13657) is what refuses it; the
// id stays in this comment rather than in the string, which reaches
// authors and operators who cannot resolve a tracker number.
`clean and the value is copied back onto the record payload unfiltered, so the write is then ` +
`REFUSED at run time — INVALID_FIELD / 400, identically on every driver (#4271). The ` +
`record is never written, and the refusal names the field far from the body that wrote it.`,
hint: fixHint(w.field, unionCandidates(targetSets)),
});
} else {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Refuse an undeclared field a before-hook writes — the post-hook half of the declared-field door, one envelope on every driver by claude[bot] · Pull Request #13864 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d80d4d0
wip(objectql): post-hook declared-field door on insert and update (#1…
claude Aug 31, 2026
558dabc
test(objectql): pin the post-hook declared-field door across three dr…
claude Aug 31, 2026
41f9f1e
test(objectql): tighten three fixtures whose hooks wrote fields their…
claude Aug 31, 2026
60aa03e
test(runtime): repoint the L2 body driver-split pin at the convergenc…
claude Aug 31, 2026
7599391
docs,lint: the L2 body undeclared-key answer is one envelope on every…
claude Aug 31, 2026
b44a658
chore: changeset for the post-hook declared-field door (#13657)
claude Aug 31, 2026
4a9731b
Merge remote-tracking branch 'origin/main' into claude/issue-13657-po…
claude Aug 31, 2026
b5fd717
lint: keep the tracker id out of the runtime message string (#13657)
claude Aug 31, 2026
30c02e7
test(objectql): drop an unused parameter from the driver double's ref…
claude Aug 31, 2026
66d1cf4
chore(gates): ratchet the query-options-erasure test surface DOWN, 24…
claude Aug 31, 2026
bc559ba
Merge remote-tracking branch 'origin/main' into claude/issue-13657-po…
claude Aug 31, 2026
901c2fa
docs(permissions): re-anchor the system-context census after this bra…
claude Aug 31, 2026
3ca55cb
Merge remote-tracking branch 'origin/main' into claude/issue-13657-me…
claude Sep 1, 2026
3e65b6a
docs(permissions): re-derive the system-context census anchors after …
claude Sep 1, 2026
e9daefb
Merge remote-tracking branch 'origin/main' into claude/issue-13657-me…
claude Sep 1, 2026
1db3d32
docs(permissions): re-derive the census anchors after the second main…
claude Sep 1, 2026
fb8232a
chore(changeset): regrade objectql to minor with the argued BREAKING …
claude Sep 1, 2026
42b8e26
Merge remote-tracking branch 'origin/main' into patch/13864-sync
claude Sep 1, 2026
9af92aa
docs(permissions): re-derive the census anchors from the merged tree
claude Sep 1, 2026
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
61 changes: 61 additions & 0 deletions .changeset/post-hook-undeclared-field-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
'@objectstack/objectql': minor
'@objectstack/lint': patch
---

Refuse an undeclared field a `before*` hook writes, identically on every driver

**BREAKING** accept-set narrowing at the post-hook write door, shipped as `minor`
under the repo's launch-window convention for breaking changes.

**Bump level, argued**: `@objectstack/objectql` is `minor`, not `patch`. A
`before*` hook or an L2 (`language:'js'`) body writing a key the object never
declares **used to succeed** on the `memory` family — the value reached the
store and persisted as a shadow column — and now **throws**, `INVALID_FIELD` /
**400**, on every driver. That is a narrowing of the accept set on the record
payload, a surface every hook body touches; it is not an instrument or a message
fix, and a hook that relied on either driver-dependent outcome stops working at
run time. The same-package sibling `.changeset/hook-input-symbol-key-refusal.md`
argues exactly this shape — "used to succeed, and now throw. That is a narrowing
of the accept set" — to `minor`, and the launch-window convention is what keeps
it off `major` (pre-1.0 lockstep semantics: a breaking change does not burn a
major version while the stack versions in lockstep — see
`scripts/check-changeset-no-major.mjs`). `patch` would under-declare a change
that turns a passing hook into a throwing one.

`'@objectstack/lint': patch` is deliberate and stays. That half of the diff is
message and comment prose only: `validateHookBodyWrites` reports the same
findings on the same bodies at the same severity, with wording that now names
the runtime refusal instead of the driver split this change retires.

The declared-field door (#8682 on insert, #8738 on update) runs before the
`before*` hooks — deliberately, so a payload about to be refused never consumes
an autonumber (#8737). That left the payload the hooks themselves produce
unjudged: a key a `beforeInsert` / `beforeUpdate` hook or an L2 (`language:'js'`)
body wrote went straight to the driver, and the drivers disagreed. `memory`
accepted it and stored a shadow column; `driver-sql` threw a raw `SQLITE_ERROR`
with no `status` and the bound statement and its values quoted back in the
message; `sqlite-wasm` threw a bare `Error` with neither. One app and one hook
meant different things on two deployments, and nothing in the app could tell
which one it was running on.

The same check now runs a second time over the post-hook payload, before any
statement is built, so a hook-written undeclared key is refused with the caller
path's envelope — `INVALID_FIELD` / **400**, `Unknown field 'x' on object 'y'` —
on every driver, because none of them is reached. The existing pre-hook door is
unchanged and stays exactly where it is.

This is a security fix as well as a consistency one: `fieldPermissions` is keyed
by declared field name and reports only fields explicitly marked non-editable, so
a key the object never declares can carry no entry and could never be gated by
field-level security. On `memory`-family stores such a value was persisted where
no view, formula, index or permission could name it.

The platform's own stamps are unaffected. `created_at` / `updated_at` — the two
the built-in audit hook writes unconditionally, because SQL drivers create them
as built-in columns on every table — are already tolerated by this check
alongside `id`; every other stamp (`created_by`, `updated_by`, `tenant_id`) is
guarded by an explicit declaration test in the hook that writes it.

<!-- adr-0087: not-required (no-migration-prescription) No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed. This narrows which run-time record payload the engine accepts after the `before*` hooks have run; an undeclared key was never a declarable metadata surface, so `objectstack migrate meta` has nothing in a stored source to rewrite. The remedy for an affected hook body is to declare the field on the object or stop writing it, which is authoring guidance, not a mechanical rewrite of stored metadata. -->

13 changes: 8 additions & 5 deletions content/docs/automation/hook-bodies.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,19 +204,22 @@ A structured `writes` declaration was considered and dropped ([#3700](https://gi

#### What still happens at runtime

An unknown field is **not** caught at runtime, and it does not fail quietly either. The write-path validator walks the object's *declared* fields, so an undeclared key is neither rejected nor stripped, and the sandbox's mutations are copied back onto the payload verbatim. What happens next is the driver's call:
An unknown field **is** caught at runtime, and the answer is the same on every driver. The sandbox's mutations are copied back onto the payload verbatim (`applyMutationsToInput` is a plain `Object.assign`) and the write-path validator still walks only the object's *declared* fields — but since [#13657](https://github.com/objectstack-ai/objectstack/issues/13657) the declared-field door runs a **second** time, over the payload the `before*` hooks produced, before any statement is built:

- **SQL drivers** put the stray column into the statement, so the **whole write fails** with a driver-level error (`table deal has no column named stagee`) — nothing is stored, and the error surfaces far from the authoring mistake.
- **Schemaless drivers** (memory, MongoDB) silently persist the stray key alongside the real ones.
```
INVALID_FIELD / 400 / Unknown field 'stagee' on object 'deal'
```

Identical on `memory`, `driver-sql` and `sqlite-wasm`, because none of them is reached. Before #13657 the driver decided instead, and the two families disagreed — SQL failed the whole write with an untyped `SQLITE_ERROR`, while schemaless drivers silently persisted the stray key as a column nothing downstream reads (and which field-level security, keyed by *declared* field name, could never gate). One app, one body, two meanings decided by which driver a deployment happened to run.

Neither outcome is the one you wanted, and the advisory warning is the earliest signal you get.
The runtime refusal is now the backstop; the advisory warning is still the earliest signal you get, and the one that names the mistake where it was made.

Because the existence check is advisory, and every write-side check here is literal-only:

- **Treat `hook-body-write-unknown-field` as a build failure by convention.** It does not gate, but the rule is tuned for near-zero false positives — in practice a warning is a real typo.
- **Check by hand what the parser cannot see.** Computed keys, spreads, aliased input and dynamic object names are invisible to the rule; for an array or `"*"` hook, every field must exist on every target.
- **Prefer a flow `update_record` node when the write set is fixed — and for *this* check most of all.** A flow node's writes are structured config: they diff field-by-field, render in the Console designer, and since [#4271](https://github.com/objectstack-ai/objectstack/issues/4271) the field-existence check gates there too — `flow-node-write-unknown-field` is an **error**, not the advisory warning a body gets, because a node's `fields` is a literal map next to a literal `objectName`: there is no parser in between that could have mis-extracted it, so a finding is a certainty rather than a best effort. (The *writability* check now has a hook-side counterpart — see [Writing a `readonly` field](#writing-a-readonly-field) below — but it covers only the `ctx.api` channel.)
- **Exercise the hook against a real object before shipping** — on SQL drivers the mistake surfaces on the first write; schemaless drivers won't tell you.
- **Exercise the hook against a real object before shipping** — the mistake surfaces on the first write, identically on every driver.

### Signature conventions

Expand Down
16 changes: 8 additions & 8 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,18 +109,18 @@ that silently does not happen.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10787` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10949` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9680` |
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10882` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11044` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9747` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1737` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9717`, `readonly-strict-errors.ts:66` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9784`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5705` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3574`, `:3584`, `:3611` |
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` |
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6403` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11535` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11464` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11630` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11559` |

### 3. Sharing (`plugin-sharing`)

Expand DownExpand Up@@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them.
| # | Site | Package | What it does |
|:--|:---|:---|:---|
| 62 | `objectql/src/engine.ts:3406` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
| 63 | `objectql/src/engine.ts:13876` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 63 | `objectql/src/engine.ts:13971` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report |
| 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |

Expand All@@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs.
|:---|:---|:---|
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` |
| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9663`–`9680` |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9730`–`9747` |
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
Expand Down
41 changes: 25 additions & 16 deletions packages/lint/src/validate-hook-body-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,24 +4,30 @@
//
// An L2 body that writes a field the target object never declares —
// `ctx.input.amout = 0`, `ctx.api.object('deal').update({ stag: 'won' })` —
// runs clean in the QuickJS sandbox and reaches the driver UNFILTERED:
// runs clean in the QuickJS sandbox and reaches the write path UNFILTERED:
// `applyMutationsToInput` (runtime/src/sandbox/body-runner.ts) is a plain
// `Object.assign`, and `validateRecord` walks declared fields on insert and
// `continue`s past a key with no field def on update. What happens after that
// is DRIVER-DEPENDENT, and neither half is acceptable:
// `continue`s past a key with no field def on update.
//
// • SQL — the stray column enters the knex statement and the WHOLE write
// fails with a driver-level error (`table deal has no column named
// stagee`). The write is lost, and the error surfaces far from the
// authoring mistake that caused it.
// • Schemaless (memory, MongoDB) — the driver spreads the payload, so the
// stray key IS persisted: an undeclared column nothing downstream reads.
// [#13657] What happens after that used to be DRIVER-DEPENDENT, and neither
// half was acceptable — SQL failed the whole write with an untyped
// `SQLITE_ERROR` far from the authoring mistake, while schemaless drivers
// (memory, MongoDB) spread the payload and PERSISTED the stray key as a column
// nothing downstream reads. #13657 closed that: the declared-field door now
// runs a second time over the payload the `before*` hooks produced, so the key
// is refused `INVALID_FIELD` / 400 identically on every driver, before any
// statement is built.
//
// Either way the mistake is invisible where it is MADE — the #4001 family, if
// not literally its silent-no-op shape. Both runtime outcomes are pinned by
// ⚠️ That does NOT retire this rule — it changes what it is worth. The runtime
// refusal arrives at WRITE time, on whichever record first exercises the
// branch; this rule arrives at AUTHOR time and names the field, the object and
// the body. The mistake is still invisible where it is MADE, which is the
// #4001 family and the whole reason for a build-time check.
//
// The runtime answer is pinned by
// `runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts`
// so this rule's wording cannot drift from what the runtime does; the same
// split is documented in `content/docs/automation/hook-bodies.mdx`.
// so this rule's wording cannot drift from what the runtime does; the same is
// documented in `content/docs/automation/hook-bodies.mdx`.
//
// The read side (`hook.condition`, ADR-0032) and the capability surface are
// statically checked; until this rule, the write side was the one blind face
Expand DownExpand Up@@ -817,9 +823,12 @@ export function validateHookBodyWrites(stack: AnyRec): HookBodyWriteFinding[] {
path,
message:
`body writes '${w.field}' to its input, but ${objDesc} ${declares}. The sandboxed script runs ` +
`clean and the value is copied back onto the record payload unfiltered — on a SQL driver the ` +
`stray column then fails the WHOLE write with a driver-level error far from here; on a ` +
`schemaless driver (memory, MongoDB) it is persisted as an undeclared key (#4271).`,
// The post-hook declared-field door (#13657) is what refuses it; the
// id stays in this comment rather than in the string, which reaches
// authors and operators who cannot resolve a tracker number.
`clean and the value is copied back onto the record payload unfiltered, so the write is then ` +
`REFUSED at run time — INVALID_FIELD / 400, identically on every driver (#4271). The ` +
`record is never written, and the refusal names the field far from the body that wrote it.`,
hint: fixHint(w.field, unionCandidates(targetSets)),
});
} else {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Refuse an undeclared field a before-hook writes — the post-hook half of the declared-field door, one envelope on every driver by claude[bot] · Pull Request #13864 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d80d4d0
wip(objectql): post-hook declared-field door on insert and update (#1…
claude Aug 31, 2026
558dabc
test(objectql): pin the post-hook declared-field door across three dr…
claude Aug 31, 2026
41f9f1e
test(objectql): tighten three fixtures whose hooks wrote fields their…
claude Aug 31, 2026
60aa03e
test(runtime): repoint the L2 body driver-split pin at the convergenc…
claude Aug 31, 2026
7599391
docs,lint: the L2 body undeclared-key answer is one envelope on every…
claude Aug 31, 2026
b44a658
chore: changeset for the post-hook declared-field door (#13657)
claude Aug 31, 2026
4a9731b
Merge remote-tracking branch 'origin/main' into claude/issue-13657-po…
claude Aug 31, 2026
b5fd717
lint: keep the tracker id out of the runtime message string (#13657)
claude Aug 31, 2026
30c02e7
test(objectql): drop an unused parameter from the driver double's ref…
claude Aug 31, 2026
66d1cf4
chore(gates): ratchet the query-options-erasure test surface DOWN, 24…
claude Aug 31, 2026
bc559ba
Merge remote-tracking branch 'origin/main' into claude/issue-13657-po…
claude Aug 31, 2026
901c2fa
docs(permissions): re-anchor the system-context census after this bra…
claude Aug 31, 2026
3ca55cb
Merge remote-tracking branch 'origin/main' into claude/issue-13657-me…
claude Sep 1, 2026
3e65b6a
docs(permissions): re-derive the system-context census anchors after …
claude Sep 1, 2026
e9daefb
Merge remote-tracking branch 'origin/main' into claude/issue-13657-me…
claude Sep 1, 2026
1db3d32
docs(permissions): re-derive the census anchors after the second main…
claude Sep 1, 2026
fb8232a
chore(changeset): regrade objectql to minor with the argued BREAKING …
claude Sep 1, 2026
42b8e26
Merge remote-tracking branch 'origin/main' into patch/13864-sync
claude Sep 1, 2026
9af92aa
docs(permissions): re-derive the census anchors from the merged tree
claude Sep 1, 2026
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
61 changes: 61 additions & 0 deletions .changeset/post-hook-undeclared-field-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
'@objectstack/objectql': minor
'@objectstack/lint': patch
---

Refuse an undeclared field a `before*` hook writes, identically on every driver

**BREAKING** accept-set narrowing at the post-hook write door, shipped as `minor`
under the repo's launch-window convention for breaking changes.

**Bump level, argued**: `@objectstack/objectql` is `minor`, not `patch`. A
`before*` hook or an L2 (`language:'js'`) body writing a key the object never
declares **used to succeed** on the `memory` family — the value reached the
store and persisted as a shadow column — and now **throws**, `INVALID_FIELD` /
**400**, on every driver. That is a narrowing of the accept set on the record
payload, a surface every hook body touches; it is not an instrument or a message
fix, and a hook that relied on either driver-dependent outcome stops working at
run time. The same-package sibling `.changeset/hook-input-symbol-key-refusal.md`
argues exactly this shape — "used to succeed, and now throw. That is a narrowing
of the accept set" — to `minor`, and the launch-window convention is what keeps
it off `major` (pre-1.0 lockstep semantics: a breaking change does not burn a
major version while the stack versions in lockstep — see
`scripts/check-changeset-no-major.mjs`). `patch` would under-declare a change
that turns a passing hook into a throwing one.

`'@objectstack/lint': patch` is deliberate and stays. That half of the diff is
message and comment prose only: `validateHookBodyWrites` reports the same
findings on the same bodies at the same severity, with wording that now names
the runtime refusal instead of the driver split this change retires.

The declared-field door (#8682 on insert, #8738 on update) runs before the
`before*` hooks — deliberately, so a payload about to be refused never consumes
an autonumber (#8737). That left the payload the hooks themselves produce
unjudged: a key a `beforeInsert` / `beforeUpdate` hook or an L2 (`language:'js'`)
body wrote went straight to the driver, and the drivers disagreed. `memory`
accepted it and stored a shadow column; `driver-sql` threw a raw `SQLITE_ERROR`
with no `status` and the bound statement and its values quoted back in the
message; `sqlite-wasm` threw a bare `Error` with neither. One app and one hook
meant different things on two deployments, and nothing in the app could tell
which one it was running on.

The same check now runs a second time over the post-hook payload, before any
statement is built, so a hook-written undeclared key is refused with the caller
path's envelope — `INVALID_FIELD` / **400**, `Unknown field 'x' on object 'y'` —
on every driver, because none of them is reached. The existing pre-hook door is
unchanged and stays exactly where it is.

This is a security fix as well as a consistency one: `fieldPermissions` is keyed
by declared field name and reports only fields explicitly marked non-editable, so
a key the object never declares can carry no entry and could never be gated by
field-level security. On `memory`-family stores such a value was persisted where
no view, formula, index or permission could name it.

The platform's own stamps are unaffected. `created_at` / `updated_at` — the two
the built-in audit hook writes unconditionally, because SQL drivers create them
as built-in columns on every table — are already tolerated by this check
alongside `id`; every other stamp (`created_by`, `updated_by`, `tenant_id`) is
guarded by an explicit declaration test in the hook that writes it.

<!-- adr-0087: not-required (no-migration-prescription) No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed. This narrows which run-time record payload the engine accepts after the `before*` hooks have run; an undeclared key was never a declarable metadata surface, so `objectstack migrate meta` has nothing in a stored source to rewrite. The remedy for an affected hook body is to declare the field on the object or stop writing it, which is authoring guidance, not a mechanical rewrite of stored metadata. -->

13 changes: 8 additions & 5 deletions content/docs/automation/hook-bodies.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,19 +204,22 @@ A structured `writes` declaration was considered and dropped ([#3700](https://gi

#### What still happens at runtime

An unknown field is **not** caught at runtime, and it does not fail quietly either. The write-path validator walks the object's *declared* fields, so an undeclared key is neither rejected nor stripped, and the sandbox's mutations are copied back onto the payload verbatim. What happens next is the driver's call:
An unknown field **is** caught at runtime, and the answer is the same on every driver. The sandbox's mutations are copied back onto the payload verbatim (`applyMutationsToInput` is a plain `Object.assign`) and the write-path validator still walks only the object's *declared* fields — but since [#13657](https://github.com/objectstack-ai/objectstack/issues/13657) the declared-field door runs a **second** time, over the payload the `before*` hooks produced, before any statement is built:

- **SQL drivers** put the stray column into the statement, so the **whole write fails** with a driver-level error (`table deal has no column named stagee`) — nothing is stored, and the error surfaces far from the authoring mistake.
- **Schemaless drivers** (memory, MongoDB) silently persist the stray key alongside the real ones.
```
INVALID_FIELD / 400 / Unknown field 'stagee' on object 'deal'
```

Identical on `memory`, `driver-sql` and `sqlite-wasm`, because none of them is reached. Before #13657 the driver decided instead, and the two families disagreed — SQL failed the whole write with an untyped `SQLITE_ERROR`, while schemaless drivers silently persisted the stray key as a column nothing downstream reads (and which field-level security, keyed by *declared* field name, could never gate). One app, one body, two meanings decided by which driver a deployment happened to run.

Neither outcome is the one you wanted, and the advisory warning is the earliest signal you get.
The runtime refusal is now the backstop; the advisory warning is still the earliest signal you get, and the one that names the mistake where it was made.

Because the existence check is advisory, and every write-side check here is literal-only:

- **Treat `hook-body-write-unknown-field` as a build failure by convention.** It does not gate, but the rule is tuned for near-zero false positives — in practice a warning is a real typo.
- **Check by hand what the parser cannot see.** Computed keys, spreads, aliased input and dynamic object names are invisible to the rule; for an array or `"*"` hook, every field must exist on every target.
- **Prefer a flow `update_record` node when the write set is fixed — and for *this* check most of all.** A flow node's writes are structured config: they diff field-by-field, render in the Console designer, and since [#4271](https://github.com/objectstack-ai/objectstack/issues/4271) the field-existence check gates there too — `flow-node-write-unknown-field` is an **error**, not the advisory warning a body gets, because a node's `fields` is a literal map next to a literal `objectName`: there is no parser in between that could have mis-extracted it, so a finding is a certainty rather than a best effort. (The *writability* check now has a hook-side counterpart — see [Writing a `readonly` field](#writing-a-readonly-field) below — but it covers only the `ctx.api` channel.)
- **Exercise the hook against a real object before shipping** — on SQL drivers the mistake surfaces on the first write; schemaless drivers won't tell you.
- **Exercise the hook against a real object before shipping** — the mistake surfaces on the first write, identically on every driver.

### Signature conventions

Expand Down
16 changes: 8 additions & 8 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,18 +109,18 @@ that silently does not happen.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10787` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10949` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9680` |
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10882` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11044` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9747` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1737` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9717`, `readonly-strict-errors.ts:66` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9784`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5705` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3574`, `:3584`, `:3611` |
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` |
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6403` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11535` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11464` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11630` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11559` |

### 3. Sharing (`plugin-sharing`)

Expand DownExpand Up@@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them.
| # | Site | Package | What it does |
|:--|:---|:---|:---|
| 62 | `objectql/src/engine.ts:3406` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
| 63 | `objectql/src/engine.ts:13876` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 63 | `objectql/src/engine.ts:13971` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report |
| 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |

Expand All@@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs.
|:---|:---|:---|
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` |
| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9663`–`9680` |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9730`–`9747` |
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
Expand Down
41 changes: 25 additions & 16 deletions packages/lint/src/validate-hook-body-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,24 +4,30 @@
//
// An L2 body that writes a field the target object never declares —
// `ctx.input.amout = 0`, `ctx.api.object('deal').update({ stag: 'won' })` —
// runs clean in the QuickJS sandbox and reaches the driver UNFILTERED:
// runs clean in the QuickJS sandbox and reaches the write path UNFILTERED:
// `applyMutationsToInput` (runtime/src/sandbox/body-runner.ts) is a plain
// `Object.assign`, and `validateRecord` walks declared fields on insert and
// `continue`s past a key with no field def on update. What happens after that
// is DRIVER-DEPENDENT, and neither half is acceptable:
// `continue`s past a key with no field def on update.
//
// • SQL — the stray column enters the knex statement and the WHOLE write
// fails with a driver-level error (`table deal has no column named
// stagee`). The write is lost, and the error surfaces far from the
// authoring mistake that caused it.
// • Schemaless (memory, MongoDB) — the driver spreads the payload, so the
// stray key IS persisted: an undeclared column nothing downstream reads.
// [#13657] What happens after that used to be DRIVER-DEPENDENT, and neither
// half was acceptable — SQL failed the whole write with an untyped
// `SQLITE_ERROR` far from the authoring mistake, while schemaless drivers
// (memory, MongoDB) spread the payload and PERSISTED the stray key as a column
// nothing downstream reads. #13657 closed that: the declared-field door now
// runs a second time over the payload the `before*` hooks produced, so the key
// is refused `INVALID_FIELD` / 400 identically on every driver, before any
// statement is built.
//
// Either way the mistake is invisible where it is MADE — the #4001 family, if
// not literally its silent-no-op shape. Both runtime outcomes are pinned by
// ⚠️ That does NOT retire this rule — it changes what it is worth. The runtime
// refusal arrives at WRITE time, on whichever record first exercises the
// branch; this rule arrives at AUTHOR time and names the field, the object and
// the body. The mistake is still invisible where it is MADE, which is the
// #4001 family and the whole reason for a build-time check.
//
// The runtime answer is pinned by
// `runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts`
// so this rule's wording cannot drift from what the runtime does; the same
// split is documented in `content/docs/automation/hook-bodies.mdx`.
// so this rule's wording cannot drift from what the runtime does; the same is
// documented in `content/docs/automation/hook-bodies.mdx`.
//
// The read side (`hook.condition`, ADR-0032) and the capability surface are
// statically checked; until this rule, the write side was the one blind face
Expand DownExpand Up@@ -817,9 +823,12 @@ export function validateHookBodyWrites(stack: AnyRec): HookBodyWriteFinding[] {
path,
message:
`body writes '${w.field}' to its input, but ${objDesc} ${declares}. The sandboxed script runs ` +
`clean and the value is copied back onto the record payload unfiltered — on a SQL driver the ` +
`stray column then fails the WHOLE write with a driver-level error far from here; on a ` +
`schemaless driver (memory, MongoDB) it is persisted as an undeclared key (#4271).`,
// The post-hook declared-field door (#13657) is what refuses it; the
// id stays in this comment rather than in the string, which reaches
// authors and operators who cannot resolve a tracker number.
`clean and the value is copied back onto the record payload unfiltered, so the write is then ` +
`REFUSED at run time — INVALID_FIELD / 400, identically on every driver (#4271). The ` +
`record is never written, and the refusal names the field far from the body that wrote it.`,
hint: fixHint(w.field, unionCandidates(targetSets)),
});
} else {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Refuse an undeclared field a before-hook writes — the post-hook half of the declared-field door, one envelope on every driver by claude[bot] · Pull Request #13864 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d80d4d0
wip(objectql): post-hook declared-field door on insert and update (#1…
claude Aug 31, 2026
558dabc
test(objectql): pin the post-hook declared-field door across three dr…
claude Aug 31, 2026
41f9f1e
test(objectql): tighten three fixtures whose hooks wrote fields their…
claude Aug 31, 2026
60aa03e
test(runtime): repoint the L2 body driver-split pin at the convergenc…
claude Aug 31, 2026
7599391
docs,lint: the L2 body undeclared-key answer is one envelope on every…
claude Aug 31, 2026
b44a658
chore: changeset for the post-hook declared-field door (#13657)
claude Aug 31, 2026
4a9731b
Merge remote-tracking branch 'origin/main' into claude/issue-13657-po…
claude Aug 31, 2026
b5fd717
lint: keep the tracker id out of the runtime message string (#13657)
claude Aug 31, 2026
30c02e7
test(objectql): drop an unused parameter from the driver double's ref…
claude Aug 31, 2026
66d1cf4
chore(gates): ratchet the query-options-erasure test surface DOWN, 24…
claude Aug 31, 2026
bc559ba
Merge remote-tracking branch 'origin/main' into claude/issue-13657-po…
claude Aug 31, 2026
901c2fa
docs(permissions): re-anchor the system-context census after this bra…
claude Aug 31, 2026
3ca55cb
Merge remote-tracking branch 'origin/main' into claude/issue-13657-me…
claude Sep 1, 2026
3e65b6a
docs(permissions): re-derive the system-context census anchors after …
claude Sep 1, 2026
e9daefb
Merge remote-tracking branch 'origin/main' into claude/issue-13657-me…
claude Sep 1, 2026
1db3d32
docs(permissions): re-derive the census anchors after the second main…
claude Sep 1, 2026
fb8232a
chore(changeset): regrade objectql to minor with the argued BREAKING …
claude Sep 1, 2026
42b8e26
Merge remote-tracking branch 'origin/main' into patch/13864-sync
claude Sep 1, 2026
9af92aa
docs(permissions): re-derive the census anchors from the merged tree
claude Sep 1, 2026
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
61 changes: 61 additions & 0 deletions .changeset/post-hook-undeclared-field-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
'@objectstack/objectql': minor
'@objectstack/lint': patch
---

Refuse an undeclared field a `before*` hook writes, identically on every driver

**BREAKING** accept-set narrowing at the post-hook write door, shipped as `minor`
under the repo's launch-window convention for breaking changes.

**Bump level, argued**: `@objectstack/objectql` is `minor`, not `patch`. A
`before*` hook or an L2 (`language:'js'`) body writing a key the object never
declares **used to succeed** on the `memory` family — the value reached the
store and persisted as a shadow column — and now **throws**, `INVALID_FIELD` /
**400**, on every driver. That is a narrowing of the accept set on the record
payload, a surface every hook body touches; it is not an instrument or a message
fix, and a hook that relied on either driver-dependent outcome stops working at
run time. The same-package sibling `.changeset/hook-input-symbol-key-refusal.md`
argues exactly this shape — "used to succeed, and now throw. That is a narrowing
of the accept set" — to `minor`, and the launch-window convention is what keeps
it off `major` (pre-1.0 lockstep semantics: a breaking change does not burn a
major version while the stack versions in lockstep — see
`scripts/check-changeset-no-major.mjs`). `patch` would under-declare a change
that turns a passing hook into a throwing one.

`'@objectstack/lint': patch` is deliberate and stays. That half of the diff is
message and comment prose only: `validateHookBodyWrites` reports the same
findings on the same bodies at the same severity, with wording that now names
the runtime refusal instead of the driver split this change retires.

The declared-field door (#8682 on insert, #8738 on update) runs before the
`before*` hooks — deliberately, so a payload about to be refused never consumes
an autonumber (#8737). That left the payload the hooks themselves produce
unjudged: a key a `beforeInsert` / `beforeUpdate` hook or an L2 (`language:'js'`)
body wrote went straight to the driver, and the drivers disagreed. `memory`
accepted it and stored a shadow column; `driver-sql` threw a raw `SQLITE_ERROR`
with no `status` and the bound statement and its values quoted back in the
message; `sqlite-wasm` threw a bare `Error` with neither. One app and one hook
meant different things on two deployments, and nothing in the app could tell
which one it was running on.

The same check now runs a second time over the post-hook payload, before any
statement is built, so a hook-written undeclared key is refused with the caller
path's envelope — `INVALID_FIELD` / **400**, `Unknown field 'x' on object 'y'` —
on every driver, because none of them is reached. The existing pre-hook door is
unchanged and stays exactly where it is.

This is a security fix as well as a consistency one: `fieldPermissions` is keyed
by declared field name and reports only fields explicitly marked non-editable, so
a key the object never declares can carry no entry and could never be gated by
field-level security. On `memory`-family stores such a value was persisted where
no view, formula, index or permission could name it.

The platform's own stamps are unaffected. `created_at` / `updated_at` — the two
the built-in audit hook writes unconditionally, because SQL drivers create them
as built-in columns on every table — are already tolerated by this check
alongside `id`; every other stamp (`created_by`, `updated_by`, `tenant_id`) is
guarded by an explicit declaration test in the hook that writes it.

<!-- adr-0087: not-required (no-migration-prescription) No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed. This narrows which run-time record payload the engine accepts after the `before*` hooks have run; an undeclared key was never a declarable metadata surface, so `objectstack migrate meta` has nothing in a stored source to rewrite. The remedy for an affected hook body is to declare the field on the object or stop writing it, which is authoring guidance, not a mechanical rewrite of stored metadata. -->

13 changes: 8 additions & 5 deletions content/docs/automation/hook-bodies.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,19 +204,22 @@ A structured `writes` declaration was considered and dropped ([#3700](https://gi

#### What still happens at runtime

An unknown field is **not** caught at runtime, and it does not fail quietly either. The write-path validator walks the object's *declared* fields, so an undeclared key is neither rejected nor stripped, and the sandbox's mutations are copied back onto the payload verbatim. What happens next is the driver's call:
An unknown field **is** caught at runtime, and the answer is the same on every driver. The sandbox's mutations are copied back onto the payload verbatim (`applyMutationsToInput` is a plain `Object.assign`) and the write-path validator still walks only the object's *declared* fields — but since [#13657](https://github.com/objectstack-ai/objectstack/issues/13657) the declared-field door runs a **second** time, over the payload the `before*` hooks produced, before any statement is built:

- **SQL drivers** put the stray column into the statement, so the **whole write fails** with a driver-level error (`table deal has no column named stagee`) — nothing is stored, and the error surfaces far from the authoring mistake.
- **Schemaless drivers** (memory, MongoDB) silently persist the stray key alongside the real ones.
```
INVALID_FIELD / 400 / Unknown field 'stagee' on object 'deal'
```

Identical on `memory`, `driver-sql` and `sqlite-wasm`, because none of them is reached. Before #13657 the driver decided instead, and the two families disagreed — SQL failed the whole write with an untyped `SQLITE_ERROR`, while schemaless drivers silently persisted the stray key as a column nothing downstream reads (and which field-level security, keyed by *declared* field name, could never gate). One app, one body, two meanings decided by which driver a deployment happened to run.

Neither outcome is the one you wanted, and the advisory warning is the earliest signal you get.
The runtime refusal is now the backstop; the advisory warning is still the earliest signal you get, and the one that names the mistake where it was made.

Because the existence check is advisory, and every write-side check here is literal-only:

- **Treat `hook-body-write-unknown-field` as a build failure by convention.** It does not gate, but the rule is tuned for near-zero false positives — in practice a warning is a real typo.
- **Check by hand what the parser cannot see.** Computed keys, spreads, aliased input and dynamic object names are invisible to the rule; for an array or `"*"` hook, every field must exist on every target.
- **Prefer a flow `update_record` node when the write set is fixed — and for *this* check most of all.** A flow node's writes are structured config: they diff field-by-field, render in the Console designer, and since [#4271](https://github.com/objectstack-ai/objectstack/issues/4271) the field-existence check gates there too — `flow-node-write-unknown-field` is an **error**, not the advisory warning a body gets, because a node's `fields` is a literal map next to a literal `objectName`: there is no parser in between that could have mis-extracted it, so a finding is a certainty rather than a best effort. (The *writability* check now has a hook-side counterpart — see [Writing a `readonly` field](#writing-a-readonly-field) below — but it covers only the `ctx.api` channel.)
- **Exercise the hook against a real object before shipping** — on SQL drivers the mistake surfaces on the first write; schemaless drivers won't tell you.
- **Exercise the hook against a real object before shipping** — the mistake surfaces on the first write, identically on every driver.

### Signature conventions

Expand Down
16 changes: 8 additions & 8 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,18 +109,18 @@ that silently does not happen.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10787` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10949` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9680` |
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10882` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11044` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9747` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1737` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9717`, `readonly-strict-errors.ts:66` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9784`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5705` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3574`, `:3584`, `:3611` |
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` |
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6403` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11535` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11464` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11630` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11559` |

### 3. Sharing (`plugin-sharing`)

Expand DownExpand Up@@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them.
| # | Site | Package | What it does |
|:--|:---|:---|:---|
| 62 | `objectql/src/engine.ts:3406` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
| 63 | `objectql/src/engine.ts:13876` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 63 | `objectql/src/engine.ts:13971` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report |
| 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |

Expand All@@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs.
|:---|:---|:---|
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` |
| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9663`–`9680` |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9730`–`9747` |
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
Expand Down
41 changes: 25 additions & 16 deletions packages/lint/src/validate-hook-body-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,24 +4,30 @@
//
// An L2 body that writes a field the target object never declares —
// `ctx.input.amout = 0`, `ctx.api.object('deal').update({ stag: 'won' })` —
// runs clean in the QuickJS sandbox and reaches the driver UNFILTERED:
// runs clean in the QuickJS sandbox and reaches the write path UNFILTERED:
// `applyMutationsToInput` (runtime/src/sandbox/body-runner.ts) is a plain
// `Object.assign`, and `validateRecord` walks declared fields on insert and
// `continue`s past a key with no field def on update. What happens after that
// is DRIVER-DEPENDENT, and neither half is acceptable:
// `continue`s past a key with no field def on update.
//
// • SQL — the stray column enters the knex statement and the WHOLE write
// fails with a driver-level error (`table deal has no column named
// stagee`). The write is lost, and the error surfaces far from the
// authoring mistake that caused it.
// • Schemaless (memory, MongoDB) — the driver spreads the payload, so the
// stray key IS persisted: an undeclared column nothing downstream reads.
// [#13657] What happens after that used to be DRIVER-DEPENDENT, and neither
// half was acceptable — SQL failed the whole write with an untyped
// `SQLITE_ERROR` far from the authoring mistake, while schemaless drivers
// (memory, MongoDB) spread the payload and PERSISTED the stray key as a column
// nothing downstream reads. #13657 closed that: the declared-field door now
// runs a second time over the payload the `before*` hooks produced, so the key
// is refused `INVALID_FIELD` / 400 identically on every driver, before any
// statement is built.
//
// Either way the mistake is invisible where it is MADE — the #4001 family, if
// not literally its silent-no-op shape. Both runtime outcomes are pinned by
// ⚠️ That does NOT retire this rule — it changes what it is worth. The runtime
// refusal arrives at WRITE time, on whichever record first exercises the
// branch; this rule arrives at AUTHOR time and names the field, the object and
// the body. The mistake is still invisible where it is MADE, which is the
// #4001 family and the whole reason for a build-time check.
//
// The runtime answer is pinned by
// `runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts`
// so this rule's wording cannot drift from what the runtime does; the same
// split is documented in `content/docs/automation/hook-bodies.mdx`.
// so this rule's wording cannot drift from what the runtime does; the same is
// documented in `content/docs/automation/hook-bodies.mdx`.
//
// The read side (`hook.condition`, ADR-0032) and the capability surface are
// statically checked; until this rule, the write side was the one blind face
Expand DownExpand Up@@ -817,9 +823,12 @@ export function validateHookBodyWrites(stack: AnyRec): HookBodyWriteFinding[] {
path,
message:
`body writes '${w.field}' to its input, but ${objDesc} ${declares}. The sandboxed script runs ` +
`clean and the value is copied back onto the record payload unfiltered — on a SQL driver the ` +
`stray column then fails the WHOLE write with a driver-level error far from here; on a ` +
`schemaless driver (memory, MongoDB) it is persisted as an undeclared key (#4271).`,
// The post-hook declared-field door (#13657) is what refuses it; the
// id stays in this comment rather than in the string, which reaches
// authors and operators who cannot resolve a tracker number.
`clean and the value is copied back onto the record payload unfiltered, so the write is then ` +
`REFUSED at run time — INVALID_FIELD / 400, identically on every driver (#4271). The ` +
`record is never written, and the refusal names the field far from the body that wrote it.`,
hint: fixHint(w.field, unionCandidates(targetSets)),
});
} else {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Refuse an undeclared field a before-hook writes — the post-hook half of the declared-field door, one envelope on every driver by claude[bot] · Pull Request #13864 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d80d4d0
wip(objectql): post-hook declared-field door on insert and update (#1…
claude Aug 31, 2026
558dabc
test(objectql): pin the post-hook declared-field door across three dr…
claude Aug 31, 2026
41f9f1e
test(objectql): tighten three fixtures whose hooks wrote fields their…
claude Aug 31, 2026
60aa03e
test(runtime): repoint the L2 body driver-split pin at the convergenc…
claude Aug 31, 2026
7599391
docs,lint: the L2 body undeclared-key answer is one envelope on every…
claude Aug 31, 2026
b44a658
chore: changeset for the post-hook declared-field door (#13657)
claude Aug 31, 2026
4a9731b
Merge remote-tracking branch 'origin/main' into claude/issue-13657-po…
claude Aug 31, 2026
b5fd717
lint: keep the tracker id out of the runtime message string (#13657)
claude Aug 31, 2026
30c02e7
test(objectql): drop an unused parameter from the driver double's ref…
claude Aug 31, 2026
66d1cf4
chore(gates): ratchet the query-options-erasure test surface DOWN, 24…
claude Aug 31, 2026
bc559ba
Merge remote-tracking branch 'origin/main' into claude/issue-13657-po…
claude Aug 31, 2026
901c2fa
docs(permissions): re-anchor the system-context census after this bra…
claude Aug 31, 2026
3ca55cb
Merge remote-tracking branch 'origin/main' into claude/issue-13657-me…
claude Sep 1, 2026
3e65b6a
docs(permissions): re-derive the system-context census anchors after …
claude Sep 1, 2026
e9daefb
Merge remote-tracking branch 'origin/main' into claude/issue-13657-me…
claude Sep 1, 2026
1db3d32
docs(permissions): re-derive the census anchors after the second main…
claude Sep 1, 2026
fb8232a
chore(changeset): regrade objectql to minor with the argued BREAKING …
claude Sep 1, 2026
42b8e26
Merge remote-tracking branch 'origin/main' into patch/13864-sync
claude Sep 1, 2026
9af92aa
docs(permissions): re-derive the census anchors from the merged tree
claude Sep 1, 2026
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
61 changes: 61 additions & 0 deletions .changeset/post-hook-undeclared-field-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
'@objectstack/objectql': minor
'@objectstack/lint': patch
---

Refuse an undeclared field a `before*` hook writes, identically on every driver

**BREAKING** accept-set narrowing at the post-hook write door, shipped as `minor`
under the repo's launch-window convention for breaking changes.

**Bump level, argued**: `@objectstack/objectql` is `minor`, not `patch`. A
`before*` hook or an L2 (`language:'js'`) body writing a key the object never
declares **used to succeed** on the `memory` family — the value reached the
store and persisted as a shadow column — and now **throws**, `INVALID_FIELD` /
**400**, on every driver. That is a narrowing of the accept set on the record
payload, a surface every hook body touches; it is not an instrument or a message
fix, and a hook that relied on either driver-dependent outcome stops working at
run time. The same-package sibling `.changeset/hook-input-symbol-key-refusal.md`
argues exactly this shape — "used to succeed, and now throw. That is a narrowing
of the accept set" — to `minor`, and the launch-window convention is what keeps
it off `major` (pre-1.0 lockstep semantics: a breaking change does not burn a
major version while the stack versions in lockstep — see
`scripts/check-changeset-no-major.mjs`). `patch` would under-declare a change
that turns a passing hook into a throwing one.

`'@objectstack/lint': patch` is deliberate and stays. That half of the diff is
message and comment prose only: `validateHookBodyWrites` reports the same
findings on the same bodies at the same severity, with wording that now names
the runtime refusal instead of the driver split this change retires.

The declared-field door (#8682 on insert, #8738 on update) runs before the
`before*` hooks — deliberately, so a payload about to be refused never consumes
an autonumber (#8737). That left the payload the hooks themselves produce
unjudged: a key a `beforeInsert` / `beforeUpdate` hook or an L2 (`language:'js'`)
body wrote went straight to the driver, and the drivers disagreed. `memory`
accepted it and stored a shadow column; `driver-sql` threw a raw `SQLITE_ERROR`
with no `status` and the bound statement and its values quoted back in the
message; `sqlite-wasm` threw a bare `Error` with neither. One app and one hook
meant different things on two deployments, and nothing in the app could tell
which one it was running on.

The same check now runs a second time over the post-hook payload, before any
statement is built, so a hook-written undeclared key is refused with the caller
path's envelope — `INVALID_FIELD` / **400**, `Unknown field 'x' on object 'y'` —
on every driver, because none of them is reached. The existing pre-hook door is
unchanged and stays exactly where it is.

This is a security fix as well as a consistency one: `fieldPermissions` is keyed
by declared field name and reports only fields explicitly marked non-editable, so
a key the object never declares can carry no entry and could never be gated by
field-level security. On `memory`-family stores such a value was persisted where
no view, formula, index or permission could name it.

The platform's own stamps are unaffected. `created_at` / `updated_at` — the two
the built-in audit hook writes unconditionally, because SQL drivers create them
as built-in columns on every table — are already tolerated by this check
alongside `id`; every other stamp (`created_by`, `updated_by`, `tenant_id`) is
guarded by an explicit declaration test in the hook that writes it.

<!-- adr-0087: not-required (no-migration-prescription) No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed. This narrows which run-time record payload the engine accepts after the `before*` hooks have run; an undeclared key was never a declarable metadata surface, so `objectstack migrate meta` has nothing in a stored source to rewrite. The remedy for an affected hook body is to declare the field on the object or stop writing it, which is authoring guidance, not a mechanical rewrite of stored metadata. -->

13 changes: 8 additions & 5 deletions content/docs/automation/hook-bodies.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,19 +204,22 @@ A structured `writes` declaration was considered and dropped ([#3700](https://gi

#### What still happens at runtime

An unknown field is **not** caught at runtime, and it does not fail quietly either. The write-path validator walks the object's *declared* fields, so an undeclared key is neither rejected nor stripped, and the sandbox's mutations are copied back onto the payload verbatim. What happens next is the driver's call:
An unknown field **is** caught at runtime, and the answer is the same on every driver. The sandbox's mutations are copied back onto the payload verbatim (`applyMutationsToInput` is a plain `Object.assign`) and the write-path validator still walks only the object's *declared* fields — but since [#13657](https://github.com/objectstack-ai/objectstack/issues/13657) the declared-field door runs a **second** time, over the payload the `before*` hooks produced, before any statement is built:

- **SQL drivers** put the stray column into the statement, so the **whole write fails** with a driver-level error (`table deal has no column named stagee`) — nothing is stored, and the error surfaces far from the authoring mistake.
- **Schemaless drivers** (memory, MongoDB) silently persist the stray key alongside the real ones.
```
INVALID_FIELD / 400 / Unknown field 'stagee' on object 'deal'
```

Identical on `memory`, `driver-sql` and `sqlite-wasm`, because none of them is reached. Before #13657 the driver decided instead, and the two families disagreed — SQL failed the whole write with an untyped `SQLITE_ERROR`, while schemaless drivers silently persisted the stray key as a column nothing downstream reads (and which field-level security, keyed by *declared* field name, could never gate). One app, one body, two meanings decided by which driver a deployment happened to run.

Neither outcome is the one you wanted, and the advisory warning is the earliest signal you get.
The runtime refusal is now the backstop; the advisory warning is still the earliest signal you get, and the one that names the mistake where it was made.

Because the existence check is advisory, and every write-side check here is literal-only:

- **Treat `hook-body-write-unknown-field` as a build failure by convention.** It does not gate, but the rule is tuned for near-zero false positives — in practice a warning is a real typo.
- **Check by hand what the parser cannot see.** Computed keys, spreads, aliased input and dynamic object names are invisible to the rule; for an array or `"*"` hook, every field must exist on every target.
- **Prefer a flow `update_record` node when the write set is fixed — and for *this* check most of all.** A flow node's writes are structured config: they diff field-by-field, render in the Console designer, and since [#4271](https://github.com/objectstack-ai/objectstack/issues/4271) the field-existence check gates there too — `flow-node-write-unknown-field` is an **error**, not the advisory warning a body gets, because a node's `fields` is a literal map next to a literal `objectName`: there is no parser in between that could have mis-extracted it, so a finding is a certainty rather than a best effort. (The *writability* check now has a hook-side counterpart — see [Writing a `readonly` field](#writing-a-readonly-field) below — but it covers only the `ctx.api` channel.)
- **Exercise the hook against a real object before shipping** — on SQL drivers the mistake surfaces on the first write; schemaless drivers won't tell you.
- **Exercise the hook against a real object before shipping** — the mistake surfaces on the first write, identically on every driver.

### Signature conventions

Expand Down
16 changes: 8 additions & 8 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,18 +109,18 @@ that silently does not happen.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10787` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10949` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9680` |
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10882` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11044` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9747` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1737` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9717`, `readonly-strict-errors.ts:66` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9784`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5705` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3574`, `:3584`, `:3611` |
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` |
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6403` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11535` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11464` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11630` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11559` |

### 3. Sharing (`plugin-sharing`)

Expand DownExpand Up@@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them.
| # | Site | Package | What it does |
|:--|:---|:---|:---|
| 62 | `objectql/src/engine.ts:3406` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
| 63 | `objectql/src/engine.ts:13876` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 63 | `objectql/src/engine.ts:13971` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report |
| 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |

Expand All@@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs.
|:---|:---|:---|
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` |
| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9663`–`9680` |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9730`–`9747` |
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
Expand Down
41 changes: 25 additions & 16 deletions packages/lint/src/validate-hook-body-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,24 +4,30 @@
//
// An L2 body that writes a field the target object never declares —
// `ctx.input.amout = 0`, `ctx.api.object('deal').update({ stag: 'won' })` —
// runs clean in the QuickJS sandbox and reaches the driver UNFILTERED:
// runs clean in the QuickJS sandbox and reaches the write path UNFILTERED:
// `applyMutationsToInput` (runtime/src/sandbox/body-runner.ts) is a plain
// `Object.assign`, and `validateRecord` walks declared fields on insert and
// `continue`s past a key with no field def on update. What happens after that
// is DRIVER-DEPENDENT, and neither half is acceptable:
// `continue`s past a key with no field def on update.
//
// • SQL — the stray column enters the knex statement and the WHOLE write
// fails with a driver-level error (`table deal has no column named
// stagee`). The write is lost, and the error surfaces far from the
// authoring mistake that caused it.
// • Schemaless (memory, MongoDB) — the driver spreads the payload, so the
// stray key IS persisted: an undeclared column nothing downstream reads.
// [#13657] What happens after that used to be DRIVER-DEPENDENT, and neither
// half was acceptable — SQL failed the whole write with an untyped
// `SQLITE_ERROR` far from the authoring mistake, while schemaless drivers
// (memory, MongoDB) spread the payload and PERSISTED the stray key as a column
// nothing downstream reads. #13657 closed that: the declared-field door now
// runs a second time over the payload the `before*` hooks produced, so the key
// is refused `INVALID_FIELD` / 400 identically on every driver, before any
// statement is built.
//
// Either way the mistake is invisible where it is MADE — the #4001 family, if
// not literally its silent-no-op shape. Both runtime outcomes are pinned by
// ⚠️ That does NOT retire this rule — it changes what it is worth. The runtime
// refusal arrives at WRITE time, on whichever record first exercises the
// branch; this rule arrives at AUTHOR time and names the field, the object and
// the body. The mistake is still invisible where it is MADE, which is the
// #4001 family and the whole reason for a build-time check.
//
// The runtime answer is pinned by
// `runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts`
// so this rule's wording cannot drift from what the runtime does; the same
// split is documented in `content/docs/automation/hook-bodies.mdx`.
// so this rule's wording cannot drift from what the runtime does; the same is
// documented in `content/docs/automation/hook-bodies.mdx`.
//
// The read side (`hook.condition`, ADR-0032) and the capability surface are
// statically checked; until this rule, the write side was the one blind face
Expand DownExpand Up@@ -817,9 +823,12 @@ export function validateHookBodyWrites(stack: AnyRec): HookBodyWriteFinding[] {
path,
message:
`body writes '${w.field}' to its input, but ${objDesc} ${declares}. The sandboxed script runs ` +
`clean and the value is copied back onto the record payload unfiltered — on a SQL driver the ` +
`stray column then fails the WHOLE write with a driver-level error far from here; on a ` +
`schemaless driver (memory, MongoDB) it is persisted as an undeclared key (#4271).`,
// The post-hook declared-field door (#13657) is what refuses it; the
// id stays in this comment rather than in the string, which reaches
// authors and operators who cannot resolve a tracker number.
`clean and the value is copied back onto the record payload unfiltered, so the write is then ` +
`REFUSED at run time — INVALID_FIELD / 400, identically on every driver (#4271). The ` +
`record is never written, and the refusal names the field far from the body that wrote it.`,
hint: fixHint(w.field, unionCandidates(targetSets)),
});
} else {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Refuse an undeclared field a before-hook writes — the post-hook half of the declared-field door, one envelope on every driver by claude[bot] · Pull Request #13864 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d80d4d0
wip(objectql): post-hook declared-field door on insert and update (#1…
claude Aug 31, 2026
558dabc
test(objectql): pin the post-hook declared-field door across three dr…
claude Aug 31, 2026
41f9f1e
test(objectql): tighten three fixtures whose hooks wrote fields their…
claude Aug 31, 2026
60aa03e
test(runtime): repoint the L2 body driver-split pin at the convergenc…
claude Aug 31, 2026
7599391
docs,lint: the L2 body undeclared-key answer is one envelope on every…
claude Aug 31, 2026
b44a658
chore: changeset for the post-hook declared-field door (#13657)
claude Aug 31, 2026
4a9731b
Merge remote-tracking branch 'origin/main' into claude/issue-13657-po…
claude Aug 31, 2026
b5fd717
lint: keep the tracker id out of the runtime message string (#13657)
claude Aug 31, 2026
30c02e7
test(objectql): drop an unused parameter from the driver double's ref…
claude Aug 31, 2026
66d1cf4
chore(gates): ratchet the query-options-erasure test surface DOWN, 24…
claude Aug 31, 2026
bc559ba
Merge remote-tracking branch 'origin/main' into claude/issue-13657-po…
claude Aug 31, 2026
901c2fa
docs(permissions): re-anchor the system-context census after this bra…
claude Aug 31, 2026
3ca55cb
Merge remote-tracking branch 'origin/main' into claude/issue-13657-me…
claude Sep 1, 2026
3e65b6a
docs(permissions): re-derive the system-context census anchors after …
claude Sep 1, 2026
e9daefb
Merge remote-tracking branch 'origin/main' into claude/issue-13657-me…
claude Sep 1, 2026
1db3d32
docs(permissions): re-derive the census anchors after the second main…
claude Sep 1, 2026
fb8232a
chore(changeset): regrade objectql to minor with the argued BREAKING …
claude Sep 1, 2026
42b8e26
Merge remote-tracking branch 'origin/main' into patch/13864-sync
claude Sep 1, 2026
9af92aa
docs(permissions): re-derive the census anchors from the merged tree
claude Sep 1, 2026
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
61 changes: 61 additions & 0 deletions .changeset/post-hook-undeclared-field-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
'@objectstack/objectql': minor
'@objectstack/lint': patch
---

Refuse an undeclared field a `before*` hook writes, identically on every driver

**BREAKING** accept-set narrowing at the post-hook write door, shipped as `minor`
under the repo's launch-window convention for breaking changes.

**Bump level, argued**: `@objectstack/objectql` is `minor`, not `patch`. A
`before*` hook or an L2 (`language:'js'`) body writing a key the object never
declares **used to succeed** on the `memory` family — the value reached the
store and persisted as a shadow column — and now **throws**, `INVALID_FIELD` /
**400**, on every driver. That is a narrowing of the accept set on the record
payload, a surface every hook body touches; it is not an instrument or a message
fix, and a hook that relied on either driver-dependent outcome stops working at
run time. The same-package sibling `.changeset/hook-input-symbol-key-refusal.md`
argues exactly this shape — "used to succeed, and now throw. That is a narrowing
of the accept set" — to `minor`, and the launch-window convention is what keeps
it off `major` (pre-1.0 lockstep semantics: a breaking change does not burn a
major version while the stack versions in lockstep — see
`scripts/check-changeset-no-major.mjs`). `patch` would under-declare a change
that turns a passing hook into a throwing one.

`'@objectstack/lint': patch` is deliberate and stays. That half of the diff is
message and comment prose only: `validateHookBodyWrites` reports the same
findings on the same bodies at the same severity, with wording that now names
the runtime refusal instead of the driver split this change retires.

The declared-field door (#8682 on insert, #8738 on update) runs before the
`before*` hooks — deliberately, so a payload about to be refused never consumes
an autonumber (#8737). That left the payload the hooks themselves produce
unjudged: a key a `beforeInsert` / `beforeUpdate` hook or an L2 (`language:'js'`)
body wrote went straight to the driver, and the drivers disagreed. `memory`
accepted it and stored a shadow column; `driver-sql` threw a raw `SQLITE_ERROR`
with no `status` and the bound statement and its values quoted back in the
message; `sqlite-wasm` threw a bare `Error` with neither. One app and one hook
meant different things on two deployments, and nothing in the app could tell
which one it was running on.

The same check now runs a second time over the post-hook payload, before any
statement is built, so a hook-written undeclared key is refused with the caller
path's envelope — `INVALID_FIELD` / **400**, `Unknown field 'x' on object 'y'` —
on every driver, because none of them is reached. The existing pre-hook door is
unchanged and stays exactly where it is.

This is a security fix as well as a consistency one: `fieldPermissions` is keyed
by declared field name and reports only fields explicitly marked non-editable, so
a key the object never declares can carry no entry and could never be gated by
field-level security. On `memory`-family stores such a value was persisted where
no view, formula, index or permission could name it.

The platform's own stamps are unaffected. `created_at` / `updated_at` — the two
the built-in audit hook writes unconditionally, because SQL drivers create them
as built-in columns on every table — are already tolerated by this check
alongside `id`; every other stamp (`created_by`, `updated_by`, `tenant_id`) is
guarded by an explicit declaration test in the hook that writes it.

<!-- adr-0087: not-required (no-migration-prescription) No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed. This narrows which run-time record payload the engine accepts after the `before*` hooks have run; an undeclared key was never a declarable metadata surface, so `objectstack migrate meta` has nothing in a stored source to rewrite. The remedy for an affected hook body is to declare the field on the object or stop writing it, which is authoring guidance, not a mechanical rewrite of stored metadata. -->

13 changes: 8 additions & 5 deletions content/docs/automation/hook-bodies.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,19 +204,22 @@ A structured `writes` declaration was considered and dropped ([#3700](https://gi

#### What still happens at runtime

An unknown field is **not** caught at runtime, and it does not fail quietly either. The write-path validator walks the object's *declared* fields, so an undeclared key is neither rejected nor stripped, and the sandbox's mutations are copied back onto the payload verbatim. What happens next is the driver's call:
An unknown field **is** caught at runtime, and the answer is the same on every driver. The sandbox's mutations are copied back onto the payload verbatim (`applyMutationsToInput` is a plain `Object.assign`) and the write-path validator still walks only the object's *declared* fields — but since [#13657](https://github.com/objectstack-ai/objectstack/issues/13657) the declared-field door runs a **second** time, over the payload the `before*` hooks produced, before any statement is built:

- **SQL drivers** put the stray column into the statement, so the **whole write fails** with a driver-level error (`table deal has no column named stagee`) — nothing is stored, and the error surfaces far from the authoring mistake.
- **Schemaless drivers** (memory, MongoDB) silently persist the stray key alongside the real ones.
```
INVALID_FIELD / 400 / Unknown field 'stagee' on object 'deal'
```

Identical on `memory`, `driver-sql` and `sqlite-wasm`, because none of them is reached. Before #13657 the driver decided instead, and the two families disagreed — SQL failed the whole write with an untyped `SQLITE_ERROR`, while schemaless drivers silently persisted the stray key as a column nothing downstream reads (and which field-level security, keyed by *declared* field name, could never gate). One app, one body, two meanings decided by which driver a deployment happened to run.

Neither outcome is the one you wanted, and the advisory warning is the earliest signal you get.
The runtime refusal is now the backstop; the advisory warning is still the earliest signal you get, and the one that names the mistake where it was made.

Because the existence check is advisory, and every write-side check here is literal-only:

- **Treat `hook-body-write-unknown-field` as a build failure by convention.** It does not gate, but the rule is tuned for near-zero false positives — in practice a warning is a real typo.
- **Check by hand what the parser cannot see.** Computed keys, spreads, aliased input and dynamic object names are invisible to the rule; for an array or `"*"` hook, every field must exist on every target.
- **Prefer a flow `update_record` node when the write set is fixed — and for *this* check most of all.** A flow node's writes are structured config: they diff field-by-field, render in the Console designer, and since [#4271](https://github.com/objectstack-ai/objectstack/issues/4271) the field-existence check gates there too — `flow-node-write-unknown-field` is an **error**, not the advisory warning a body gets, because a node's `fields` is a literal map next to a literal `objectName`: there is no parser in between that could have mis-extracted it, so a finding is a certainty rather than a best effort. (The *writability* check now has a hook-side counterpart — see [Writing a `readonly` field](#writing-a-readonly-field) below — but it covers only the `ctx.api` channel.)
- **Exercise the hook against a real object before shipping** — on SQL drivers the mistake surfaces on the first write; schemaless drivers won't tell you.
- **Exercise the hook against a real object before shipping** — the mistake surfaces on the first write, identically on every driver.

### Signature conventions

Expand Down
16 changes: 8 additions & 8 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,18 +109,18 @@ that silently does not happen.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10787` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10949` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9680` |
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10882` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11044` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9747` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1737` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9717`, `readonly-strict-errors.ts:66` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9784`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5705` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3574`, `:3584`, `:3611` |
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` |
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6403` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11535` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11464` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11630` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11559` |

### 3. Sharing (`plugin-sharing`)

Expand DownExpand Up@@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them.
| # | Site | Package | What it does |
|:--|:---|:---|:---|
| 62 | `objectql/src/engine.ts:3406` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
| 63 | `objectql/src/engine.ts:13876` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 63 | `objectql/src/engine.ts:13971` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report |
| 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |

Expand All@@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs.
|:---|:---|:---|
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` |
| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9663`–`9680` |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9730`–`9747` |
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
Expand Down
41 changes: 25 additions & 16 deletions packages/lint/src/validate-hook-body-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,24 +4,30 @@
//
// An L2 body that writes a field the target object never declares —
// `ctx.input.amout = 0`, `ctx.api.object('deal').update({ stag: 'won' })` —
// runs clean in the QuickJS sandbox and reaches the driver UNFILTERED:
// runs clean in the QuickJS sandbox and reaches the write path UNFILTERED:
// `applyMutationsToInput` (runtime/src/sandbox/body-runner.ts) is a plain
// `Object.assign`, and `validateRecord` walks declared fields on insert and
// `continue`s past a key with no field def on update. What happens after that
// is DRIVER-DEPENDENT, and neither half is acceptable:
// `continue`s past a key with no field def on update.
//
// • SQL — the stray column enters the knex statement and the WHOLE write
// fails with a driver-level error (`table deal has no column named
// stagee`). The write is lost, and the error surfaces far from the
// authoring mistake that caused it.
// • Schemaless (memory, MongoDB) — the driver spreads the payload, so the
// stray key IS persisted: an undeclared column nothing downstream reads.
// [#13657] What happens after that used to be DRIVER-DEPENDENT, and neither
// half was acceptable — SQL failed the whole write with an untyped
// `SQLITE_ERROR` far from the authoring mistake, while schemaless drivers
// (memory, MongoDB) spread the payload and PERSISTED the stray key as a column
// nothing downstream reads. #13657 closed that: the declared-field door now
// runs a second time over the payload the `before*` hooks produced, so the key
// is refused `INVALID_FIELD` / 400 identically on every driver, before any
// statement is built.
//
// Either way the mistake is invisible where it is MADE — the #4001 family, if
// not literally its silent-no-op shape. Both runtime outcomes are pinned by
// ⚠️ That does NOT retire this rule — it changes what it is worth. The runtime
// refusal arrives at WRITE time, on whichever record first exercises the
// branch; this rule arrives at AUTHOR time and names the field, the object and
// the body. The mistake is still invisible where it is MADE, which is the
// #4001 family and the whole reason for a build-time check.
//
// The runtime answer is pinned by
// `runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts`
// so this rule's wording cannot drift from what the runtime does; the same
// split is documented in `content/docs/automation/hook-bodies.mdx`.
// so this rule's wording cannot drift from what the runtime does; the same is
// documented in `content/docs/automation/hook-bodies.mdx`.
//
// The read side (`hook.condition`, ADR-0032) and the capability surface are
// statically checked; until this rule, the write side was the one blind face
Expand DownExpand Up@@ -817,9 +823,12 @@ export function validateHookBodyWrites(stack: AnyRec): HookBodyWriteFinding[] {
path,
message:
`body writes '${w.field}' to its input, but ${objDesc} ${declares}. The sandboxed script runs ` +
`clean and the value is copied back onto the record payload unfiltered — on a SQL driver the ` +
`stray column then fails the WHOLE write with a driver-level error far from here; on a ` +
`schemaless driver (memory, MongoDB) it is persisted as an undeclared key (#4271).`,
// The post-hook declared-field door (#13657) is what refuses it; the
// id stays in this comment rather than in the string, which reaches
// authors and operators who cannot resolve a tracker number.
`clean and the value is copied back onto the record payload unfiltered, so the write is then ` +
`REFUSED at run time — INVALID_FIELD / 400, identically on every driver (#4271). The ` +
`record is never written, and the refusal names the field far from the body that wrote it.`,
hint: fixHint(w.field, unionCandidates(targetSets)),
});
} else {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Refuse an undeclared field a before-hook writes — the post-hook half of the declared-field door, one envelope on every driver by claude[bot] · Pull Request #13864 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d80d4d0
wip(objectql): post-hook declared-field door on insert and update (#1…
claude Aug 31, 2026
558dabc
test(objectql): pin the post-hook declared-field door across three dr…
claude Aug 31, 2026
41f9f1e
test(objectql): tighten three fixtures whose hooks wrote fields their…
claude Aug 31, 2026
60aa03e
test(runtime): repoint the L2 body driver-split pin at the convergenc…
claude Aug 31, 2026
7599391
docs,lint: the L2 body undeclared-key answer is one envelope on every…
claude Aug 31, 2026
b44a658
chore: changeset for the post-hook declared-field door (#13657)
claude Aug 31, 2026
4a9731b
Merge remote-tracking branch 'origin/main' into claude/issue-13657-po…
claude Aug 31, 2026
b5fd717
lint: keep the tracker id out of the runtime message string (#13657)
claude Aug 31, 2026
30c02e7
test(objectql): drop an unused parameter from the driver double's ref…
claude Aug 31, 2026
66d1cf4
chore(gates): ratchet the query-options-erasure test surface DOWN, 24…
claude Aug 31, 2026
bc559ba
Merge remote-tracking branch 'origin/main' into claude/issue-13657-po…
claude Aug 31, 2026
901c2fa
docs(permissions): re-anchor the system-context census after this bra…
claude Aug 31, 2026
3ca55cb
Merge remote-tracking branch 'origin/main' into claude/issue-13657-me…
claude Sep 1, 2026
3e65b6a
docs(permissions): re-derive the system-context census anchors after …
claude Sep 1, 2026
e9daefb
Merge remote-tracking branch 'origin/main' into claude/issue-13657-me…
claude Sep 1, 2026
1db3d32
docs(permissions): re-derive the census anchors after the second main…
claude Sep 1, 2026
fb8232a
chore(changeset): regrade objectql to minor with the argued BREAKING …
claude Sep 1, 2026
42b8e26
Merge remote-tracking branch 'origin/main' into patch/13864-sync
claude Sep 1, 2026
9af92aa
docs(permissions): re-derive the census anchors from the merged tree
claude Sep 1, 2026
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
61 changes: 61 additions & 0 deletions .changeset/post-hook-undeclared-field-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
'@objectstack/objectql': minor
'@objectstack/lint': patch
---

Refuse an undeclared field a `before*` hook writes, identically on every driver

**BREAKING** accept-set narrowing at the post-hook write door, shipped as `minor`
under the repo's launch-window convention for breaking changes.

**Bump level, argued**: `@objectstack/objectql` is `minor`, not `patch`. A
`before*` hook or an L2 (`language:'js'`) body writing a key the object never
declares **used to succeed** on the `memory` family — the value reached the
store and persisted as a shadow column — and now **throws**, `INVALID_FIELD` /
**400**, on every driver. That is a narrowing of the accept set on the record
payload, a surface every hook body touches; it is not an instrument or a message
fix, and a hook that relied on either driver-dependent outcome stops working at
run time. The same-package sibling `.changeset/hook-input-symbol-key-refusal.md`
argues exactly this shape — "used to succeed, and now throw. That is a narrowing
of the accept set" — to `minor`, and the launch-window convention is what keeps
it off `major` (pre-1.0 lockstep semantics: a breaking change does not burn a
major version while the stack versions in lockstep — see
`scripts/check-changeset-no-major.mjs`). `patch` would under-declare a change
that turns a passing hook into a throwing one.

`'@objectstack/lint': patch` is deliberate and stays. That half of the diff is
message and comment prose only: `validateHookBodyWrites` reports the same
findings on the same bodies at the same severity, with wording that now names
the runtime refusal instead of the driver split this change retires.

The declared-field door (#8682 on insert, #8738 on update) runs before the
`before*` hooks — deliberately, so a payload about to be refused never consumes
an autonumber (#8737). That left the payload the hooks themselves produce
unjudged: a key a `beforeInsert` / `beforeUpdate` hook or an L2 (`language:'js'`)
body wrote went straight to the driver, and the drivers disagreed. `memory`
accepted it and stored a shadow column; `driver-sql` threw a raw `SQLITE_ERROR`
with no `status` and the bound statement and its values quoted back in the
message; `sqlite-wasm` threw a bare `Error` with neither. One app and one hook
meant different things on two deployments, and nothing in the app could tell
which one it was running on.

The same check now runs a second time over the post-hook payload, before any
statement is built, so a hook-written undeclared key is refused with the caller
path's envelope — `INVALID_FIELD` / **400**, `Unknown field 'x' on object 'y'` —
on every driver, because none of them is reached. The existing pre-hook door is
unchanged and stays exactly where it is.

This is a security fix as well as a consistency one: `fieldPermissions` is keyed
by declared field name and reports only fields explicitly marked non-editable, so
a key the object never declares can carry no entry and could never be gated by
field-level security. On `memory`-family stores such a value was persisted where
no view, formula, index or permission could name it.

The platform's own stamps are unaffected. `created_at` / `updated_at` — the two
the built-in audit hook writes unconditionally, because SQL drivers create them
as built-in columns on every table — are already tolerated by this check
alongside `id`; every other stamp (`created_by`, `updated_by`, `tenant_id`) is
guarded by an explicit declaration test in the hook that writes it.

<!-- adr-0087: not-required (no-migration-prescription) No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed. This narrows which run-time record payload the engine accepts after the `before*` hooks have run; an undeclared key was never a declarable metadata surface, so `objectstack migrate meta` has nothing in a stored source to rewrite. The remedy for an affected hook body is to declare the field on the object or stop writing it, which is authoring guidance, not a mechanical rewrite of stored metadata. -->

13 changes: 8 additions & 5 deletions content/docs/automation/hook-bodies.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,19 +204,22 @@ A structured `writes` declaration was considered and dropped ([#3700](https://gi

#### What still happens at runtime

An unknown field is **not** caught at runtime, and it does not fail quietly either. The write-path validator walks the object's *declared* fields, so an undeclared key is neither rejected nor stripped, and the sandbox's mutations are copied back onto the payload verbatim. What happens next is the driver's call:
An unknown field **is** caught at runtime, and the answer is the same on every driver. The sandbox's mutations are copied back onto the payload verbatim (`applyMutationsToInput` is a plain `Object.assign`) and the write-path validator still walks only the object's *declared* fields — but since [#13657](https://github.com/objectstack-ai/objectstack/issues/13657) the declared-field door runs a **second** time, over the payload the `before*` hooks produced, before any statement is built:

- **SQL drivers** put the stray column into the statement, so the **whole write fails** with a driver-level error (`table deal has no column named stagee`) — nothing is stored, and the error surfaces far from the authoring mistake.
- **Schemaless drivers** (memory, MongoDB) silently persist the stray key alongside the real ones.
```
INVALID_FIELD / 400 / Unknown field 'stagee' on object 'deal'
```

Identical on `memory`, `driver-sql` and `sqlite-wasm`, because none of them is reached. Before #13657 the driver decided instead, and the two families disagreed — SQL failed the whole write with an untyped `SQLITE_ERROR`, while schemaless drivers silently persisted the stray key as a column nothing downstream reads (and which field-level security, keyed by *declared* field name, could never gate). One app, one body, two meanings decided by which driver a deployment happened to run.

Neither outcome is the one you wanted, and the advisory warning is the earliest signal you get.
The runtime refusal is now the backstop; the advisory warning is still the earliest signal you get, and the one that names the mistake where it was made.

Because the existence check is advisory, and every write-side check here is literal-only:

- **Treat `hook-body-write-unknown-field` as a build failure by convention.** It does not gate, but the rule is tuned for near-zero false positives — in practice a warning is a real typo.
- **Check by hand what the parser cannot see.** Computed keys, spreads, aliased input and dynamic object names are invisible to the rule; for an array or `"*"` hook, every field must exist on every target.
- **Prefer a flow `update_record` node when the write set is fixed — and for *this* check most of all.** A flow node's writes are structured config: they diff field-by-field, render in the Console designer, and since [#4271](https://github.com/objectstack-ai/objectstack/issues/4271) the field-existence check gates there too — `flow-node-write-unknown-field` is an **error**, not the advisory warning a body gets, because a node's `fields` is a literal map next to a literal `objectName`: there is no parser in between that could have mis-extracted it, so a finding is a certainty rather than a best effort. (The *writability* check now has a hook-side counterpart — see [Writing a `readonly` field](#writing-a-readonly-field) below — but it covers only the `ctx.api` channel.)
- **Exercise the hook against a real object before shipping** — on SQL drivers the mistake surfaces on the first write; schemaless drivers won't tell you.
- **Exercise the hook against a real object before shipping** — the mistake surfaces on the first write, identically on every driver.

### Signature conventions

Expand Down
16 changes: 8 additions & 8 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,18 +109,18 @@ that silently does not happen.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10787` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10949` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9680` |
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10882` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11044` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9747` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1737` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9717`, `readonly-strict-errors.ts:66` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9784`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5705` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3574`, `:3584`, `:3611` |
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` |
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6403` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11535` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11464` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11630` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11559` |

### 3. Sharing (`plugin-sharing`)

Expand DownExpand Up@@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them.
| # | Site | Package | What it does |
|:--|:---|:---|:---|
| 62 | `objectql/src/engine.ts:3406` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
| 63 | `objectql/src/engine.ts:13876` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 63 | `objectql/src/engine.ts:13971` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report |
| 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |

Expand All@@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs.
|:---|:---|:---|
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` |
| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9663`–`9680` |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9730`–`9747` |
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
Expand Down
41 changes: 25 additions & 16 deletions packages/lint/src/validate-hook-body-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,24 +4,30 @@
//
// An L2 body that writes a field the target object never declares —
// `ctx.input.amout = 0`, `ctx.api.object('deal').update({ stag: 'won' })` —
// runs clean in the QuickJS sandbox and reaches the driver UNFILTERED:
// runs clean in the QuickJS sandbox and reaches the write path UNFILTERED:
// `applyMutationsToInput` (runtime/src/sandbox/body-runner.ts) is a plain
// `Object.assign`, and `validateRecord` walks declared fields on insert and
// `continue`s past a key with no field def on update. What happens after that
// is DRIVER-DEPENDENT, and neither half is acceptable:
// `continue`s past a key with no field def on update.
//
// • SQL — the stray column enters the knex statement and the WHOLE write
// fails with a driver-level error (`table deal has no column named
// stagee`). The write is lost, and the error surfaces far from the
// authoring mistake that caused it.
// • Schemaless (memory, MongoDB) — the driver spreads the payload, so the
// stray key IS persisted: an undeclared column nothing downstream reads.
// [#13657] What happens after that used to be DRIVER-DEPENDENT, and neither
// half was acceptable — SQL failed the whole write with an untyped
// `SQLITE_ERROR` far from the authoring mistake, while schemaless drivers
// (memory, MongoDB) spread the payload and PERSISTED the stray key as a column
// nothing downstream reads. #13657 closed that: the declared-field door now
// runs a second time over the payload the `before*` hooks produced, so the key
// is refused `INVALID_FIELD` / 400 identically on every driver, before any
// statement is built.
//
// Either way the mistake is invisible where it is MADE — the #4001 family, if
// not literally its silent-no-op shape. Both runtime outcomes are pinned by
// ⚠️ That does NOT retire this rule — it changes what it is worth. The runtime
// refusal arrives at WRITE time, on whichever record first exercises the
// branch; this rule arrives at AUTHOR time and names the field, the object and
// the body. The mistake is still invisible where it is MADE, which is the
// #4001 family and the whole reason for a build-time check.
//
// The runtime answer is pinned by
// `runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts`
// so this rule's wording cannot drift from what the runtime does; the same
// split is documented in `content/docs/automation/hook-bodies.mdx`.
// so this rule's wording cannot drift from what the runtime does; the same is
// documented in `content/docs/automation/hook-bodies.mdx`.
//
// The read side (`hook.condition`, ADR-0032) and the capability surface are
// statically checked; until this rule, the write side was the one blind face
Expand DownExpand Up@@ -817,9 +823,12 @@ export function validateHookBodyWrites(stack: AnyRec): HookBodyWriteFinding[] {
path,
message:
`body writes '${w.field}' to its input, but ${objDesc} ${declares}. The sandboxed script runs ` +
`clean and the value is copied back onto the record payload unfiltered — on a SQL driver the ` +
`stray column then fails the WHOLE write with a driver-level error far from here; on a ` +
`schemaless driver (memory, MongoDB) it is persisted as an undeclared key (#4271).`,
// The post-hook declared-field door (#13657) is what refuses it; the
// id stays in this comment rather than in the string, which reaches
// authors and operators who cannot resolve a tracker number.
`clean and the value is copied back onto the record payload unfiltered, so the write is then ` +
`REFUSED at run time — INVALID_FIELD / 400, identically on every driver (#4271). The ` +
`record is never written, and the refusal names the field far from the body that wrote it.`,
hint: fixHint(w.field, unionCandidates(targetSets)),
});
} else {
Expand Down
Loading
Loading