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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .changeset/action-body-write-set-lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ that carries one. An action body is the same artefact: the same
`HookBodySchema` union, parsed by the same `HookBodySchema.safeParse` in
`actionBodyRunnerFactory`, run in the same QuickJS sandbox. So it fails the
same way — `ctx.api.object('crm_deal').update({ stag: 'won' })` inside an
action succeeds, returns success to the caller, and the unknown column simply
never lands. Half the surface was still blind.
action reaches the driver unfiltered, and the outcome splits by driver: on SQL
the stray column fails the whole call with a driver-level error far from the
authoring site, and on a schemaless driver the stray key is persisted. Half
the surface was still blind.

**New rule — `action-body-write-unknown-field` (advisory).** Wired into
`REFERENCE_INTEGRITY_RULES`, so `os validate`, `os lint` and `os compile` all
Expand Down
41 changes: 41 additions & 0 deletions .changeset/body-write-lint-message-driver-truth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@objectstack/lint": patch
"@objectstack/spec": patch
---

fix(lint): the write-set diagnostics describe what the runtime actually does (#4271)

`hook-body-write-unknown-field` and `action-body-write-unknown-field` told
authors the undeclared column "silently never lands in the stored record".
Measured on `main`, that is wrong in **both** directions. Nothing between the
body and the driver filters the key — `applyMutationsToInput` is a plain
`Object.assign`, and `validateRecord` walks declared fields on insert and
`continue`s past a key with no field def on update — so the driver decides:

- **SQL** — the stray column enters the statement and the **whole write
fails** with a driver-level error (`table deal has no column named stagee`).
Nothing is stored, so the correctly-spelled fields of that row are lost too,
and the error names a column far from the body that wrote it.
- **Schemaless** (memory, MongoDB — both spread the payload without consulting
the declared field set) — the stray key **is** persisted, as an undeclared
column nothing downstream reads.

A lint that misdescribes the failure it is warning about teaches the wrong
debugging instinct: an author told the value silently vanishes will not connect
the driver error they actually see to the typo that caused it, and on a
schemaless driver will not go looking for the stray key that is really there.
All three messages now state the split, matching the "What still happens at
runtime" description #4355 gave `content/docs/automation/hook-bodies.mdx`.

Both outcomes are pinned by a new integration test —
`runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts`.
Its insert cases run the full chain (real QuickJS sandbox, real hook body, real
engine, real driver against a real SQLite table), so "reaches the driver
unfiltered" is proved rather than asserted: if anything on that path ever
learns to filter, the SQL half stops throwing and the test goes red. The rule
headers, the `ScriptBodySchema` / `ActionSchema.body` notes and the two
still-unreleased #4271 changesets are corrected to match. #4355 fixed the
prose docs; this is the same correction on the surfaces that ship in the
packages — the diagnostic an author actually reads, and a test that pins it.

`@objectstack/spec`: doc comments only — no schema or generated-artifact change.
23 changes: 17 additions & 6 deletions .changeset/hook-body-write-set-lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,23 @@ feat(lint): L2 hook-body writes to undeclared fields warn at author time (#4271)

An L2 (`language:'js'`) hook body that writes a field the target object never
declares — `ctx.input.amout = 0`, `ctx.api.object('deal').update({ stag: … })`
— runs clean in the QuickJS sandbox, reports success, and the unknown column
simply never lands in the stored record. No diagnostic anywhere: the #4001
"silent no-op manufactures false completion" failure mode at the
runtime-expression layer. The read side (`hook.condition`) and the capability
surface were already statically checked; the write side was the one blind face,
and `hook-body.zod.ts` carried it as an **accepted gap**.
— runs clean in the QuickJS sandbox and reaches the driver **unfiltered**:
`applyMutationsToInput` is a plain `Object.assign`, and the write-path
validator walks declared fields on insert and skips a key it has no field def
for on update. What happens next depends on the driver, and neither half is
acceptable:

- **SQL** — the stray column enters the 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 mistake that caused it.
- **Schemaless** (memory, MongoDB) — the driver spreads the payload, so the
stray key **is** persisted: an undeclared column nothing downstream reads.

No diagnostic anywhere, and nothing at the authoring site either way — the
#4001 "the mistake is invisible where it is made" family. The read side
(`hook.condition`) and the capability surface were already statically checked;
the write side was the one blind face, and `hook-body.zod.ts` carried it as an
**accepted gap**.

**New rule — `hook-body-write-unknown-field` (advisory).** `@objectstack/lint`
now parses each L2 body (TypeScript parser; parsed, never executed, never
Expand Down
8 changes: 5 additions & 3 deletions packages/lint/src/reference-integrity-suite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ describe('reference-integrity suite — every member actually runs', () => {
// validateObjectReferences: a param pointing at an object nothing declares.
{ name: 'assign', label: 'Assign', params: [{ name: 'owner', reference: 'user' }] },
// validateActionBodyWrites, both of its rule ids from one body:
// - the L2 body persists a field crm_lead does not declare — the action
// returns success and the column never lands (#4271);
// - the L2 body writes a field crm_lead does not declare — on SQL that
// fails the whole call at the driver, on a schemaless driver it
// persists a stray key (#4271);
// - and it assigns ctx.record, a snapshot the runtime never writes
// back, without ever passing it anywhere (#4345).
{
Expand Down Expand Up @@ -143,7 +144,8 @@ describe('reference-integrity suite — every member actually runs', () => {
skills: [{ name: 'metadata_authoring', surface: 'build', tools: ['forecast_revenue'] }],
hooks: [
// validateHookBodyWrites: the L2 body writes a field crm_lead does not
// declare — runs clean in the sandbox, never lands in the record (#4271).
// declare — runs clean in the sandbox, then fails the whole write at a
// SQL driver / persists a stray key on a schemaless one (#4271).
{
name: 'score_lead',
object: 'crm_lead',
Expand Down
18 changes: 11 additions & 7 deletions packages/lint/src/validate-action-body-writes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
// An action body is the same artefact as a hook body: the same
// `HookBodySchema` union, parsed by the same `HookBodySchema.safeParse` in
// `actionBodyRunnerFactory` (packages/runtime/src/sandbox/body-runner.ts), run
// in the same QuickJS sandbox. So it fails the same way — an action that
// persists a field the target object never declares runs clean, returns
// success to the caller, and the unknown column simply never lands. Same
// silent no-op, same #4001 family; the hook rule alone left half the surface
// uncovered.
// in the same QuickJS sandbox. So it fails the same way — an action body that
// writes a field the target object never declares reaches the driver
// unfiltered, and the outcome is DRIVER-DEPENDENT: on SQL the stray column
// fails the whole call with a driver-level error far from the authoring
// mistake, on a schemaless driver the stray key is persisted. Same #4271
// split as the hook side (see that file's header for the measured chain, and
// `undeclared-field-write-driver-split.integration.test.ts` for the pin); the
// hook rule alone left half the surface uncovered.
//
// ─── What does NOT carry over ───────────────────────────────────────────────
//
Expand Down Expand Up @@ -331,8 +334,9 @@ export function validateActionBodyWrites(stack: AnyRec): ActionBodyWriteFinding[
path: site.path,
message:
`body calls ctx.api.object('${w.object}').${w.method ?? 'update'}(…) writing '${w.field}', but ` +
`object '${w.object}' declares no such field. The action returns success while the unknown column ` +
`silently never lands (#4271).`,
`object '${w.object}' declares no such field. The write-path validator skips the unknown key — ` +
`on a SQL driver the whole action then fails with a driver-level error far from here; on a ` +
`schemaless driver (memory, MongoDB) the stray key is persisted (#4271).`,
hint: fixHint(w.field, [...known]),
});
}
Expand Down
39 changes: 28 additions & 11 deletions packages/lint/src/validate-hook-body-writes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,28 @@
//
// 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, reports success, and the unknown column
// simply never lands in the stored record. No diagnostic anywhere: the exact
// "silent no-op manufactures false completion" failure mode of #4001, at the
// runtime-expression layer. 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 (the gap `hook-body.zod.ts` used to carry as
// "accepted").
// runs clean in the QuickJS sandbox and reaches the driver 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:
//
// • 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.
//
// 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
// `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`.
//
// 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
// (the gap `hook-body.zod.ts` used to carry as "accepted").
//
// Scope — the literal write patterns in {@link HOOK_BODY_WRITE_PATTERNS}, and
// nothing else. The body is PARSED (TypeScript parser, never executed, never
Expand Down Expand Up @@ -586,8 +601,9 @@ 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 — then the unknown column silently ` +
`never lands in the stored record (#4271).`,
`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).`,
hint: fixHint(w.field, unionCandidates(targetSets)),
});
} else {
Expand All @@ -604,8 +620,9 @@ export function validateHookBodyWrites(stack: AnyRec): HookBodyWriteFinding[] {
path,
message:
`body calls ctx.api.object('${w.object}').${w.method ?? 'update'}(…) writing '${w.field}', but ` +
`object '${w.object}' declares no such field. The call succeeds while the unknown column silently ` +
`never lands (#4271).`,
`object '${w.object}' declares no such field. The write-path validator skips the unknown key — ` +
`on a SQL driver the whole call then fails with a driver-level error far from here; on a ` +
`schemaless driver (memory, MongoDB) the stray key is persisted (#4271).`,
hint: fixHint(w.field, [...known]),
});
}
Expand Down
Loading
Loading