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
53 changes: 53 additions & 0 deletions .changeset/write-set-messages-drop-driver-split.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/lint": patch
---

fix(lint): the three write-set rule messages now state the refusal authors actually get, not a retired driver split (#13858)

Message text only. Rule ids, severities, match sets and hints are untouched, and
no finding changes shape — but a lint's own header states why the prose is
governed: *"a lint that misdescribes the failure it is warning about teaches the
wrong debugging instinct"*. These three sentences did.

`validate-hook-body-writes` (the `ctx.api` branch), `validate-action-body-writes`
and `validate-flow-node-writes` all told the author that an undeclared write has
a **driver-dependent** outcome:

> 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

For the paths those three rules judge, that has not been true since the
declared-field door landed (#8682 insert, #8738 update). All three describe a
write whose payload is **caller-supplied**, not a mutation of an in-flight
`ctx.input`: `ctx.api` is a `ScopedContext` over the running engine, and a flow
node hands its `fields` map to the data engine directly. The door refuses a
caller-named undeclared key from the object's field map **before any statement is
built**, so no driver is reached and there is no split to observe.

Measured before the prose was rewritten — all three paths, both driver families,
through a real QuickJS sandbox, a real `ObjectQL` engine, the real
`AutomationEngine` with the real builtin CRUD node executors, real
`@objectstack/driver-sql` (better-sqlite3) and real `@objectstack/driver-memory`:

| path | driver-sql | driver-memory |
|---|---|---|
| hook body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
| action body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
| flow `create_record` / `update_record` `fields` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |

Every run answered `Unknown field 'stagee' on object 'deal'`; nothing was stored
on either family, and the schemaless family kept **no** shadow column — the half
the old message promised and the runtime no longer delivers.

The three messages now name that refusal in the vocabulary the `ctx.input`
sibling landed with (`REFUSED at run time — INVALID_FIELD / 400, identically on
every driver`), say why the door and not a driver answers, and keep each path's
own blast radius: the hook refusal fails the operation that triggered the hook,
the action refusal fails the action, and the flow node's refusal is whole — the
correctly named fields in the same payload never land either, `create_record`
never creates the row, and the step fails the run. That last clause is why the
flow rule still gates at `error`; the severity is unchanged.

`unprovisionedAnchorWriteConsequence()` in the same files is **untouched**: an
ADR-0015 external object's injected anchor *is* declared in the registered
schema, so it passes the door by construction and the remote database really is
what refuses it. That message was already correct.
29 changes: 29 additions & 0 deletions packages/lint/src/validate-action-body-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,35 @@ describe('validateActionBodyWrites — ctx.api writes', () => {
expect(findings[0].hint).toContain("'discount_total'");
});

// [#13858] The same rewrite as the hook sibling, from the same measurement:
// real QuickJS sandbox, a real L2 ACTION body run through
// `actionBodyRunnerFactory`, a real ObjectQL engine, real driver-sql
// (better-sqlite3) AND real driver-memory. Both families answered
// `INVALID_FIELD` / 400, "Unknown field 'stagee' on object 'deal'"; the
// target row was untouched and the memory family stored no shadow column.
// The old text promised a driver-level error on SQL and a persisted stray
// key on schemaless — neither happens on this path, and has not since
// #8682/#8738 put the declared-field door ahead of any statement.
it('states the measured refusal — INVALID_FIELD / 400 on every driver — and no driver split', () => {
const [finding] = validateActionBodyWrites(
stackWith("await ctx.api.object('crm_deal').update({ discont_total: 0 });"),
);

expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every driver');
expect(finding.message).toContain('before any statement is built');
// The reason the door — not a driver — is what answers.
expect(finding.message).toContain('ordinary CALLER write');
// The action-side blast radius, the one word that differs from the hook
// sibling's sentence. Pinned so a future sweep cannot flatten the two.
expect(finding.message).toContain('fails the action');

expect(finding.message).not.toMatch(/driver-level error/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/write-path validator skips/);
});

it('checks insert/create/update payloads (argument 0) and updateById at argument 1', () => {
const findings = validateActionBodyWrites(
stackWith(
Expand Down
30 changes: 20 additions & 10 deletions packages/lint/src/validate-action-body-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,18 @@
// `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 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.
// writes a field the target object never declares is refused at run time, far
// from the authoring mistake. [#13858] That refusal is NOT driver-dependent,
// and the message says so: this rule judges exactly one shape,
// `ctx.api.object('<literal>').insert|create|update|updateById(…)`, and
// `ctx.api` is a ScopedContext over the running engine, so the payload is
// CALLER-supplied and the declared-field door (#8682 insert, #8738 update)
// refuses it — `INVALID_FIELD` / 400, identically on driver-sql and
// driver-memory, before any statement is built. Measured on both families
// through the real sandbox and the real engine; the caller-payload half of
// that door is pinned in
// `undeclared-field-write-driver-split.integration.test.ts`. The hook rule
// alone left half the surface uncovered, which is why this file exists.
//
// ─── What does NOT carry over ───────────────────────────────────────────────
//
Expand DownExpand Up@@ -429,9 +434,14 @@ 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 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).`,
// [#13858] Same door, same measurement as the hook sibling — ctx.api
// is a ScopedContext over the running engine, so this payload is
// CALLER-supplied and #8682/#8738 refuse it before any driver.
`object '${w.object}' declares no such field. ctx.api is a scoped handle on the running ` +
`engine, so the payload arrives as an ordinary CALLER write and the declared-field door ` +
`REFUSES it at run time — INVALID_FIELD / 400, identically on every driver (#4271), before ` +
`any statement is built. The write lands nothing, and the refusal escapes the body and ` +
`fails the action.`,
hint: fixHint(w.field, [...known]),
});
}
Expand Down
35 changes: 35 additions & 0 deletions packages/lint/src/validate-flow-node-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -181,6 +181,41 @@ describe('validateFlowNodeWrites', () => {
expect(findings[0].hint).toMatch(/Did you mean (one of: )?'stage'/);
});

// [#13858] This rule GATES (severity `error`), so its message is what an
// author reads while their build is refused — the one place a wrong causal
// story costs the most. It used to say "on a SQL datasource the driver
// rejects the whole statement ('no such column') … on a schemaless one the
// stray key is persisted". Measured through the real AutomationEngine, the
// real builtin CRUD nodes, a real ObjectQL engine and BOTH families
// (driver-sql on better-sqlite3, driver-memory): neither happens. Both
// answered `INVALID_FIELD` / 400, "Unknown field 'stagee' on object 'deal'",
// the node folded that into `create_record(deal) failed: …`, the run failed,
// and nothing was stored on either family — no row on create, an untouched
// row and no shadow column on update.
it('states the measured refusal — INVALID_FIELD / 400 on every datasource — and no driver split', () => {
const [finding] = validateFlowNodeWrites({
objects: [dealObject],
flows: [flowWith({ stagee: 'won' })],
});

expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every datasource');
expect(finding.message).toContain('before any statement is built');
// Why the door answers and not a datasource: the node hands `fields`
// straight to the data engine, so it is a caller payload.
expect(finding.message).toContain('ordinary caller payload');
// The severity's own justification, unchanged by the rewrite and still
// stated: the refusal is WHOLE, so correctly named siblings are lost too.
expect(finding.message).toContain('never land either');
expect(finding.message).toContain('the step fails the run');

// The retired driver split, both halves.
expect(finding.message).not.toMatch(/no such column/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/Nothing between the node and storage/);
});

it('flags every unknown key in one node, and only those', () => {
const findings = validateFlowNodeWrites({
objects: [dealObject],
Expand Down
60 changes: 35 additions & 25 deletions packages/lint/src/validate-flow-node-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,27 +26,31 @@
//
// And the runtime consequence is not the benign "consumer skips the unknown
// name and does the rest" that keeps `page-field-unknown` / `form-field-unknown`
// advisory. Nothing between the node and storage removes the key: the flow
// executor calls the data engine directly (bypassing the metadata-protocol
// ingress, which strips `readonly` — not unknown — keys anyway), the engine's
// write paths strip only readonly/readonlyWhen, and the SQL driver's
// `formatInput` / `applyWriteColumnMap` pass an unrecognized key straight
// through (`m[k] ?? k`). Every branch below was measured, not inferred:
// advisory. The flow executor calls the data engine directly (`data.insert` /
// `data.update` in service-automation's `builtin/crud-nodes.ts`, bypassing the
// metadata-protocol ingress), so the node's `fields` map arrives as an ordinary
// CALLER payload — and [#13858] the declared-field door (#8682 insert, #8738
// update) refuses a caller-named undeclared key from the object's field map
// before any statement is built. Every branch below was measured through the
// real AutomationEngine, the real builtin CRUD nodes, the real engine and BOTH
// driver families (driver-sql on better-sqlite3, driver-memory), not inferred:
//
// • Through the engine, an undeclared key reaches `driver.update` /
// `driver.create` verbatim, alongside the audit stamps.
// • On SQLite/knex an UPDATE becomes `update "deal" set "name" = 'n2',
// "stagee" = 'won' … → no such column: stagee`. The statement is rejected
// WHOLE: `name` — spelled correctly, in the same payload — does not land
// either, and the step fails with a driver error naming a column, far from
// the authoring mistake.
// • An INSERT fails the same way (`table deal has no column named stagee`),
// and one notch harder: the row is never created at all, so every later
// node that expected `{<node>.id}` is working from a record that does not
// exist.
// • On a schemaless datasource (memory, MongoDB) nothing rejects it, so the
// stray key is persisted into a column the object never declares — where no
// schema-driven read surface will return it.
// • Both families answer identically — `INVALID_FIELD` / 400, "Unknown field
// 'stagee' on object 'deal'". No driver is reached, so there is no split to
// observe.
// • The write is refused WHOLE: `name` — spelled correctly, in the same
// payload — does not land either.
// • On `create_record` the row is never created at all, so every later node
// that expected `{<node>.id}` is working from a record that does not exist.
// • The node catches the refusal and folds it into a step failure
// (`create_record(deal) failed: Unknown field 'stagee' on object 'deal'`),
// so the RUN fails — far from the authoring mistake, which is exactly why
// an author-time rule is still worth having.
//
// ⚠️ Until #13858 this block described the pre-#8682 driver split (SQL rejected
// the statement, a schemaless datasource persisted the stray key). That is
// retired, not merely restated: the severity below is unchanged because neither
// the old outcome nor the new one is ever "the rest still works".
//
// No outcome is "the rest still works". That is the same call
// `validate-searchable-fields` makes for a stale entry and
Expand DownExpand Up@@ -290,12 +294,18 @@ export function validateFlowNodeWrites(stack: AnyRec): FlowNodeWriteFinding[] {
where: `flow "${flowName}" › ${nodeWhere}`,
path: `${nodePath}.config.fields.${fieldName}`,
message:
`${node.type} writes '${fieldName}', but object '${objectName}' declares no such field. Nothing ` +
`between the node and storage removes the key: on a SQL datasource the driver rejects the whole ` +
`statement ('no such column'), so the correctly named fields in this same payload never land ` +
`either${
// [#13858] The node hands `fields` to the data engine directly
// (`data.insert` / `data.update` in service-automation's
// crud-nodes), so it is a CALLER payload and the #8682/#8738
// declared-field door refuses it before any datasource is reached.
// Measured on driver-sql and driver-memory alike.
`${node.type} writes '${fieldName}', but object '${objectName}' declares no such field. The ` +
`node hands its fields map to the engine as an ordinary caller payload, so the ` +
`declared-field door REFUSES the whole write — INVALID_FIELD / 400, identically on every ` +
`datasource, before any statement is built. The correctly named fields in this same payload ` +
`never land either${
node.type === 'create_record' ? ' and the record is never created at all' : ''
}; on a schemaless one the stray key is persisted into a column no read surface returns.`,
}, and the step fails the run.`,
hint: fixHint(fieldName, [...known]),
});
}
Expand Down
37 changes: 37 additions & 0 deletions packages/lint/src/validate-hook-body-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -246,6 +246,43 @@ describe('validateHookBodyWrites — ctx.api writes', () => {
expect(findings[0].hint).toContain("'email'");
});

// [#13858] The message is the whole product of an advisory rule, so the
// sentence IS the deliverable. It used to promise a driver-dependent outcome
// ("on a SQL driver … a driver-level error; on a schemaless driver … the
// stray key is persisted"), which has not been true for this path since
// #8682/#8738: `ctx.api` is a ScopedContext over the running engine, so the
// payload is CALLER-supplied and the declared-field door refuses it first.
//
// Measured before this text was written — real QuickJS sandbox, real hook
// body, real ObjectQL, real driver-sql (better-sqlite3) AND real
// driver-memory: both families answered `INVALID_FIELD` / 400, "Unknown field
// 'stagee' on object 'deal'", the target row was untouched, and the memory
// family stored no shadow column. Same door the caller-payload half of
// `undeclared-field-write-driver-split.integration.test.ts` pins.
it('states the measured refusal — INVALID_FIELD / 400 on every driver — and no driver split', () => {
const [finding] = validateHookBodyWrites(
stackWith("await ctx.api.object('crm_deal').update({ id, stag: 'won' });"),
);

// What the author actually gets, in the vocabulary #13657 landed for the
// `ctx.input` sibling one branch over — one door, one phrasing.
expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every driver');
expect(finding.message).toContain('before any statement is built');
// Why it is refused there rather than by a driver: the payload is a
// CALLER's, which is the fact the whole rewrite turns on.
expect(finding.message).toContain('ordinary CALLER write');
// ...and the blast radius that makes an author-time rule worth having.
expect(finding.message).toContain('fails the operation that triggered the hook');

// The retired claim, in both halves. Neither may come back without a
// measurement saying it should.
expect(finding.message).not.toMatch(/driver-level error/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/write-path validator skips/);
});

it('checks updateById payloads at argument 1, not 0', () => {
const findings = validateHookBodyWrites(
stackWith("await ctx.api.object('crm_deal').updateById(ctx.input.id, { stag: 'won' });"),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
53 changes: 53 additions & 0 deletions .changeset/write-set-messages-drop-driver-split.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/lint": patch
---

fix(lint): the three write-set rule messages now state the refusal authors actually get, not a retired driver split (#13858)

Message text only. Rule ids, severities, match sets and hints are untouched, and
no finding changes shape — but a lint's own header states why the prose is
governed: *"a lint that misdescribes the failure it is warning about teaches the
wrong debugging instinct"*. These three sentences did.

`validate-hook-body-writes` (the `ctx.api` branch), `validate-action-body-writes`
and `validate-flow-node-writes` all told the author that an undeclared write has
a **driver-dependent** outcome:

> 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

For the paths those three rules judge, that has not been true since the
declared-field door landed (#8682 insert, #8738 update). All three describe a
write whose payload is **caller-supplied**, not a mutation of an in-flight
`ctx.input`: `ctx.api` is a `ScopedContext` over the running engine, and a flow
node hands its `fields` map to the data engine directly. The door refuses a
caller-named undeclared key from the object's field map **before any statement is
built**, so no driver is reached and there is no split to observe.

Measured before the prose was rewritten — all three paths, both driver families,
through a real QuickJS sandbox, a real `ObjectQL` engine, the real
`AutomationEngine` with the real builtin CRUD node executors, real
`@objectstack/driver-sql` (better-sqlite3) and real `@objectstack/driver-memory`:

| path | driver-sql | driver-memory |
|---|---|---|
| hook body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
| action body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
| flow `create_record` / `update_record` `fields` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |

Every run answered `Unknown field 'stagee' on object 'deal'`; nothing was stored
on either family, and the schemaless family kept **no** shadow column — the half
the old message promised and the runtime no longer delivers.

The three messages now name that refusal in the vocabulary the `ctx.input`
sibling landed with (`REFUSED at run time — INVALID_FIELD / 400, identically on
every driver`), say why the door and not a driver answers, and keep each path's
own blast radius: the hook refusal fails the operation that triggered the hook,
the action refusal fails the action, and the flow node's refusal is whole — the
correctly named fields in the same payload never land either, `create_record`
never creates the row, and the step fails the run. That last clause is why the
flow rule still gates at `error`; the severity is unchanged.

`unprovisionedAnchorWriteConsequence()` in the same files is **untouched**: an
ADR-0015 external object's injected anchor *is* declared in the registered
schema, so it passes the door by construction and the remote database really is
what refuses it. That message was already correct.
29 changes: 29 additions & 0 deletions packages/lint/src/validate-action-body-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,35 @@ describe('validateActionBodyWrites — ctx.api writes', () => {
expect(findings[0].hint).toContain("'discount_total'");
});

// [#13858] The same rewrite as the hook sibling, from the same measurement:
// real QuickJS sandbox, a real L2 ACTION body run through
// `actionBodyRunnerFactory`, a real ObjectQL engine, real driver-sql
// (better-sqlite3) AND real driver-memory. Both families answered
// `INVALID_FIELD` / 400, "Unknown field 'stagee' on object 'deal'"; the
// target row was untouched and the memory family stored no shadow column.
// The old text promised a driver-level error on SQL and a persisted stray
// key on schemaless — neither happens on this path, and has not since
// #8682/#8738 put the declared-field door ahead of any statement.
it('states the measured refusal — INVALID_FIELD / 400 on every driver — and no driver split', () => {
const [finding] = validateActionBodyWrites(
stackWith("await ctx.api.object('crm_deal').update({ discont_total: 0 });"),
);

expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every driver');
expect(finding.message).toContain('before any statement is built');
// The reason the door — not a driver — is what answers.
expect(finding.message).toContain('ordinary CALLER write');
// The action-side blast radius, the one word that differs from the hook
// sibling's sentence. Pinned so a future sweep cannot flatten the two.
expect(finding.message).toContain('fails the action');

expect(finding.message).not.toMatch(/driver-level error/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/write-path validator skips/);
});

it('checks insert/create/update payloads (argument 0) and updateById at argument 1', () => {
const findings = validateActionBodyWrites(
stackWith(
Expand Down
30 changes: 20 additions & 10 deletions packages/lint/src/validate-action-body-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,18 @@
// `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 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.
// writes a field the target object never declares is refused at run time, far
// from the authoring mistake. [#13858] That refusal is NOT driver-dependent,
// and the message says so: this rule judges exactly one shape,
// `ctx.api.object('<literal>').insert|create|update|updateById(…)`, and
// `ctx.api` is a ScopedContext over the running engine, so the payload is
// CALLER-supplied and the declared-field door (#8682 insert, #8738 update)
// refuses it — `INVALID_FIELD` / 400, identically on driver-sql and
// driver-memory, before any statement is built. Measured on both families
// through the real sandbox and the real engine; the caller-payload half of
// that door is pinned in
// `undeclared-field-write-driver-split.integration.test.ts`. The hook rule
// alone left half the surface uncovered, which is why this file exists.
//
// ─── What does NOT carry over ───────────────────────────────────────────────
//
Expand DownExpand Up@@ -429,9 +434,14 @@ 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 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).`,
// [#13858] Same door, same measurement as the hook sibling — ctx.api
// is a ScopedContext over the running engine, so this payload is
// CALLER-supplied and #8682/#8738 refuse it before any driver.
`object '${w.object}' declares no such field. ctx.api is a scoped handle on the running ` +
`engine, so the payload arrives as an ordinary CALLER write and the declared-field door ` +
`REFUSES it at run time — INVALID_FIELD / 400, identically on every driver (#4271), before ` +
`any statement is built. The write lands nothing, and the refusal escapes the body and ` +
`fails the action.`,
hint: fixHint(w.field, [...known]),
});
}
Expand Down
35 changes: 35 additions & 0 deletions packages/lint/src/validate-flow-node-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -181,6 +181,41 @@ describe('validateFlowNodeWrites', () => {
expect(findings[0].hint).toMatch(/Did you mean (one of: )?'stage'/);
});

// [#13858] This rule GATES (severity `error`), so its message is what an
// author reads while their build is refused — the one place a wrong causal
// story costs the most. It used to say "on a SQL datasource the driver
// rejects the whole statement ('no such column') … on a schemaless one the
// stray key is persisted". Measured through the real AutomationEngine, the
// real builtin CRUD nodes, a real ObjectQL engine and BOTH families
// (driver-sql on better-sqlite3, driver-memory): neither happens. Both
// answered `INVALID_FIELD` / 400, "Unknown field 'stagee' on object 'deal'",
// the node folded that into `create_record(deal) failed: …`, the run failed,
// and nothing was stored on either family — no row on create, an untouched
// row and no shadow column on update.
it('states the measured refusal — INVALID_FIELD / 400 on every datasource — and no driver split', () => {
const [finding] = validateFlowNodeWrites({
objects: [dealObject],
flows: [flowWith({ stagee: 'won' })],
});

expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every datasource');
expect(finding.message).toContain('before any statement is built');
// Why the door answers and not a datasource: the node hands `fields`
// straight to the data engine, so it is a caller payload.
expect(finding.message).toContain('ordinary caller payload');
// The severity's own justification, unchanged by the rewrite and still
// stated: the refusal is WHOLE, so correctly named siblings are lost too.
expect(finding.message).toContain('never land either');
expect(finding.message).toContain('the step fails the run');

// The retired driver split, both halves.
expect(finding.message).not.toMatch(/no such column/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/Nothing between the node and storage/);
});

it('flags every unknown key in one node, and only those', () => {
const findings = validateFlowNodeWrites({
objects: [dealObject],
Expand Down
60 changes: 35 additions & 25 deletions packages/lint/src/validate-flow-node-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,27 +26,31 @@
//
// And the runtime consequence is not the benign "consumer skips the unknown
// name and does the rest" that keeps `page-field-unknown` / `form-field-unknown`
// advisory. Nothing between the node and storage removes the key: the flow
// executor calls the data engine directly (bypassing the metadata-protocol
// ingress, which strips `readonly` — not unknown — keys anyway), the engine's
// write paths strip only readonly/readonlyWhen, and the SQL driver's
// `formatInput` / `applyWriteColumnMap` pass an unrecognized key straight
// through (`m[k] ?? k`). Every branch below was measured, not inferred:
// advisory. The flow executor calls the data engine directly (`data.insert` /
// `data.update` in service-automation's `builtin/crud-nodes.ts`, bypassing the
// metadata-protocol ingress), so the node's `fields` map arrives as an ordinary
// CALLER payload — and [#13858] the declared-field door (#8682 insert, #8738
// update) refuses a caller-named undeclared key from the object's field map
// before any statement is built. Every branch below was measured through the
// real AutomationEngine, the real builtin CRUD nodes, the real engine and BOTH
// driver families (driver-sql on better-sqlite3, driver-memory), not inferred:
//
// • Through the engine, an undeclared key reaches `driver.update` /
// `driver.create` verbatim, alongside the audit stamps.
// • On SQLite/knex an UPDATE becomes `update "deal" set "name" = 'n2',
// "stagee" = 'won' … → no such column: stagee`. The statement is rejected
// WHOLE: `name` — spelled correctly, in the same payload — does not land
// either, and the step fails with a driver error naming a column, far from
// the authoring mistake.
// • An INSERT fails the same way (`table deal has no column named stagee`),
// and one notch harder: the row is never created at all, so every later
// node that expected `{<node>.id}` is working from a record that does not
// exist.
// • On a schemaless datasource (memory, MongoDB) nothing rejects it, so the
// stray key is persisted into a column the object never declares — where no
// schema-driven read surface will return it.
// • Both families answer identically — `INVALID_FIELD` / 400, "Unknown field
// 'stagee' on object 'deal'". No driver is reached, so there is no split to
// observe.
// • The write is refused WHOLE: `name` — spelled correctly, in the same
// payload — does not land either.
// • On `create_record` the row is never created at all, so every later node
// that expected `{<node>.id}` is working from a record that does not exist.
// • The node catches the refusal and folds it into a step failure
// (`create_record(deal) failed: Unknown field 'stagee' on object 'deal'`),
// so the RUN fails — far from the authoring mistake, which is exactly why
// an author-time rule is still worth having.
//
// ⚠️ Until #13858 this block described the pre-#8682 driver split (SQL rejected
// the statement, a schemaless datasource persisted the stray key). That is
// retired, not merely restated: the severity below is unchanged because neither
// the old outcome nor the new one is ever "the rest still works".
//
// No outcome is "the rest still works". That is the same call
// `validate-searchable-fields` makes for a stale entry and
Expand DownExpand Up@@ -290,12 +294,18 @@ export function validateFlowNodeWrites(stack: AnyRec): FlowNodeWriteFinding[] {
where: `flow "${flowName}" › ${nodeWhere}`,
path: `${nodePath}.config.fields.${fieldName}`,
message:
`${node.type} writes '${fieldName}', but object '${objectName}' declares no such field. Nothing ` +
`between the node and storage removes the key: on a SQL datasource the driver rejects the whole ` +
`statement ('no such column'), so the correctly named fields in this same payload never land ` +
`either${
// [#13858] The node hands `fields` to the data engine directly
// (`data.insert` / `data.update` in service-automation's
// crud-nodes), so it is a CALLER payload and the #8682/#8738
// declared-field door refuses it before any datasource is reached.
// Measured on driver-sql and driver-memory alike.
`${node.type} writes '${fieldName}', but object '${objectName}' declares no such field. The ` +
`node hands its fields map to the engine as an ordinary caller payload, so the ` +
`declared-field door REFUSES the whole write — INVALID_FIELD / 400, identically on every ` +
`datasource, before any statement is built. The correctly named fields in this same payload ` +
`never land either${
node.type === 'create_record' ? ' and the record is never created at all' : ''
}; on a schemaless one the stray key is persisted into a column no read surface returns.`,
}, and the step fails the run.`,
hint: fixHint(fieldName, [...known]),
});
}
Expand Down
37 changes: 37 additions & 0 deletions packages/lint/src/validate-hook-body-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -246,6 +246,43 @@ describe('validateHookBodyWrites — ctx.api writes', () => {
expect(findings[0].hint).toContain("'email'");
});

// [#13858] The message is the whole product of an advisory rule, so the
// sentence IS the deliverable. It used to promise a driver-dependent outcome
// ("on a SQL driver … a driver-level error; on a schemaless driver … the
// stray key is persisted"), which has not been true for this path since
// #8682/#8738: `ctx.api` is a ScopedContext over the running engine, so the
// payload is CALLER-supplied and the declared-field door refuses it first.
//
// Measured before this text was written — real QuickJS sandbox, real hook
// body, real ObjectQL, real driver-sql (better-sqlite3) AND real
// driver-memory: both families answered `INVALID_FIELD` / 400, "Unknown field
// 'stagee' on object 'deal'", the target row was untouched, and the memory
// family stored no shadow column. Same door the caller-payload half of
// `undeclared-field-write-driver-split.integration.test.ts` pins.
it('states the measured refusal — INVALID_FIELD / 400 on every driver — and no driver split', () => {
const [finding] = validateHookBodyWrites(
stackWith("await ctx.api.object('crm_deal').update({ id, stag: 'won' });"),
);

// What the author actually gets, in the vocabulary #13657 landed for the
// `ctx.input` sibling one branch over — one door, one phrasing.
expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every driver');
expect(finding.message).toContain('before any statement is built');
// Why it is refused there rather than by a driver: the payload is a
// CALLER's, which is the fact the whole rewrite turns on.
expect(finding.message).toContain('ordinary CALLER write');
// ...and the blast radius that makes an author-time rule worth having.
expect(finding.message).toContain('fails the operation that triggered the hook');

// The retired claim, in both halves. Neither may come back without a
// measurement saying it should.
expect(finding.message).not.toMatch(/driver-level error/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/write-path validator skips/);
});

it('checks updateById payloads at argument 1, not 0', () => {
const findings = validateHookBodyWrites(
stackWith("await ctx.api.object('crm_deal').updateById(ctx.input.id, { stag: 'won' });"),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
53 changes: 53 additions & 0 deletions .changeset/write-set-messages-drop-driver-split.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/lint": patch
---

fix(lint): the three write-set rule messages now state the refusal authors actually get, not a retired driver split (#13858)

Message text only. Rule ids, severities, match sets and hints are untouched, and
no finding changes shape — but a lint's own header states why the prose is
governed: *"a lint that misdescribes the failure it is warning about teaches the
wrong debugging instinct"*. These three sentences did.

`validate-hook-body-writes` (the `ctx.api` branch), `validate-action-body-writes`
and `validate-flow-node-writes` all told the author that an undeclared write has
a **driver-dependent** outcome:

> 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

For the paths those three rules judge, that has not been true since the
declared-field door landed (#8682 insert, #8738 update). All three describe a
write whose payload is **caller-supplied**, not a mutation of an in-flight
`ctx.input`: `ctx.api` is a `ScopedContext` over the running engine, and a flow
node hands its `fields` map to the data engine directly. The door refuses a
caller-named undeclared key from the object's field map **before any statement is
built**, so no driver is reached and there is no split to observe.

Measured before the prose was rewritten — all three paths, both driver families,
through a real QuickJS sandbox, a real `ObjectQL` engine, the real
`AutomationEngine` with the real builtin CRUD node executors, real
`@objectstack/driver-sql` (better-sqlite3) and real `@objectstack/driver-memory`:

| path | driver-sql | driver-memory |
|---|---|---|
| hook body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
| action body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
| flow `create_record` / `update_record` `fields` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |

Every run answered `Unknown field 'stagee' on object 'deal'`; nothing was stored
on either family, and the schemaless family kept **no** shadow column — the half
the old message promised and the runtime no longer delivers.

The three messages now name that refusal in the vocabulary the `ctx.input`
sibling landed with (`REFUSED at run time — INVALID_FIELD / 400, identically on
every driver`), say why the door and not a driver answers, and keep each path's
own blast radius: the hook refusal fails the operation that triggered the hook,
the action refusal fails the action, and the flow node's refusal is whole — the
correctly named fields in the same payload never land either, `create_record`
never creates the row, and the step fails the run. That last clause is why the
flow rule still gates at `error`; the severity is unchanged.

`unprovisionedAnchorWriteConsequence()` in the same files is **untouched**: an
ADR-0015 external object's injected anchor *is* declared in the registered
schema, so it passes the door by construction and the remote database really is
what refuses it. That message was already correct.
29 changes: 29 additions & 0 deletions packages/lint/src/validate-action-body-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,35 @@ describe('validateActionBodyWrites — ctx.api writes', () => {
expect(findings[0].hint).toContain("'discount_total'");
});

// [#13858] The same rewrite as the hook sibling, from the same measurement:
// real QuickJS sandbox, a real L2 ACTION body run through
// `actionBodyRunnerFactory`, a real ObjectQL engine, real driver-sql
// (better-sqlite3) AND real driver-memory. Both families answered
// `INVALID_FIELD` / 400, "Unknown field 'stagee' on object 'deal'"; the
// target row was untouched and the memory family stored no shadow column.
// The old text promised a driver-level error on SQL and a persisted stray
// key on schemaless — neither happens on this path, and has not since
// #8682/#8738 put the declared-field door ahead of any statement.
it('states the measured refusal — INVALID_FIELD / 400 on every driver — and no driver split', () => {
const [finding] = validateActionBodyWrites(
stackWith("await ctx.api.object('crm_deal').update({ discont_total: 0 });"),
);

expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every driver');
expect(finding.message).toContain('before any statement is built');
// The reason the door — not a driver — is what answers.
expect(finding.message).toContain('ordinary CALLER write');
// The action-side blast radius, the one word that differs from the hook
// sibling's sentence. Pinned so a future sweep cannot flatten the two.
expect(finding.message).toContain('fails the action');

expect(finding.message).not.toMatch(/driver-level error/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/write-path validator skips/);
});

it('checks insert/create/update payloads (argument 0) and updateById at argument 1', () => {
const findings = validateActionBodyWrites(
stackWith(
Expand Down
30 changes: 20 additions & 10 deletions packages/lint/src/validate-action-body-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,18 @@
// `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 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.
// writes a field the target object never declares is refused at run time, far
// from the authoring mistake. [#13858] That refusal is NOT driver-dependent,
// and the message says so: this rule judges exactly one shape,
// `ctx.api.object('<literal>').insert|create|update|updateById(…)`, and
// `ctx.api` is a ScopedContext over the running engine, so the payload is
// CALLER-supplied and the declared-field door (#8682 insert, #8738 update)
// refuses it — `INVALID_FIELD` / 400, identically on driver-sql and
// driver-memory, before any statement is built. Measured on both families
// through the real sandbox and the real engine; the caller-payload half of
// that door is pinned in
// `undeclared-field-write-driver-split.integration.test.ts`. The hook rule
// alone left half the surface uncovered, which is why this file exists.
//
// ─── What does NOT carry over ───────────────────────────────────────────────
//
Expand DownExpand Up@@ -429,9 +434,14 @@ 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 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).`,
// [#13858] Same door, same measurement as the hook sibling — ctx.api
// is a ScopedContext over the running engine, so this payload is
// CALLER-supplied and #8682/#8738 refuse it before any driver.
`object '${w.object}' declares no such field. ctx.api is a scoped handle on the running ` +
`engine, so the payload arrives as an ordinary CALLER write and the declared-field door ` +
`REFUSES it at run time — INVALID_FIELD / 400, identically on every driver (#4271), before ` +
`any statement is built. The write lands nothing, and the refusal escapes the body and ` +
`fails the action.`,
hint: fixHint(w.field, [...known]),
});
}
Expand Down
35 changes: 35 additions & 0 deletions packages/lint/src/validate-flow-node-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -181,6 +181,41 @@ describe('validateFlowNodeWrites', () => {
expect(findings[0].hint).toMatch(/Did you mean (one of: )?'stage'/);
});

// [#13858] This rule GATES (severity `error`), so its message is what an
// author reads while their build is refused — the one place a wrong causal
// story costs the most. It used to say "on a SQL datasource the driver
// rejects the whole statement ('no such column') … on a schemaless one the
// stray key is persisted". Measured through the real AutomationEngine, the
// real builtin CRUD nodes, a real ObjectQL engine and BOTH families
// (driver-sql on better-sqlite3, driver-memory): neither happens. Both
// answered `INVALID_FIELD` / 400, "Unknown field 'stagee' on object 'deal'",
// the node folded that into `create_record(deal) failed: …`, the run failed,
// and nothing was stored on either family — no row on create, an untouched
// row and no shadow column on update.
it('states the measured refusal — INVALID_FIELD / 400 on every datasource — and no driver split', () => {
const [finding] = validateFlowNodeWrites({
objects: [dealObject],
flows: [flowWith({ stagee: 'won' })],
});

expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every datasource');
expect(finding.message).toContain('before any statement is built');
// Why the door answers and not a datasource: the node hands `fields`
// straight to the data engine, so it is a caller payload.
expect(finding.message).toContain('ordinary caller payload');
// The severity's own justification, unchanged by the rewrite and still
// stated: the refusal is WHOLE, so correctly named siblings are lost too.
expect(finding.message).toContain('never land either');
expect(finding.message).toContain('the step fails the run');

// The retired driver split, both halves.
expect(finding.message).not.toMatch(/no such column/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/Nothing between the node and storage/);
});

it('flags every unknown key in one node, and only those', () => {
const findings = validateFlowNodeWrites({
objects: [dealObject],
Expand Down
60 changes: 35 additions & 25 deletions packages/lint/src/validate-flow-node-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,27 +26,31 @@
//
// And the runtime consequence is not the benign "consumer skips the unknown
// name and does the rest" that keeps `page-field-unknown` / `form-field-unknown`
// advisory. Nothing between the node and storage removes the key: the flow
// executor calls the data engine directly (bypassing the metadata-protocol
// ingress, which strips `readonly` — not unknown — keys anyway), the engine's
// write paths strip only readonly/readonlyWhen, and the SQL driver's
// `formatInput` / `applyWriteColumnMap` pass an unrecognized key straight
// through (`m[k] ?? k`). Every branch below was measured, not inferred:
// advisory. The flow executor calls the data engine directly (`data.insert` /
// `data.update` in service-automation's `builtin/crud-nodes.ts`, bypassing the
// metadata-protocol ingress), so the node's `fields` map arrives as an ordinary
// CALLER payload — and [#13858] the declared-field door (#8682 insert, #8738
// update) refuses a caller-named undeclared key from the object's field map
// before any statement is built. Every branch below was measured through the
// real AutomationEngine, the real builtin CRUD nodes, the real engine and BOTH
// driver families (driver-sql on better-sqlite3, driver-memory), not inferred:
//
// • Through the engine, an undeclared key reaches `driver.update` /
// `driver.create` verbatim, alongside the audit stamps.
// • On SQLite/knex an UPDATE becomes `update "deal" set "name" = 'n2',
// "stagee" = 'won' … → no such column: stagee`. The statement is rejected
// WHOLE: `name` — spelled correctly, in the same payload — does not land
// either, and the step fails with a driver error naming a column, far from
// the authoring mistake.
// • An INSERT fails the same way (`table deal has no column named stagee`),
// and one notch harder: the row is never created at all, so every later
// node that expected `{<node>.id}` is working from a record that does not
// exist.
// • On a schemaless datasource (memory, MongoDB) nothing rejects it, so the
// stray key is persisted into a column the object never declares — where no
// schema-driven read surface will return it.
// • Both families answer identically — `INVALID_FIELD` / 400, "Unknown field
// 'stagee' on object 'deal'". No driver is reached, so there is no split to
// observe.
// • The write is refused WHOLE: `name` — spelled correctly, in the same
// payload — does not land either.
// • On `create_record` the row is never created at all, so every later node
// that expected `{<node>.id}` is working from a record that does not exist.
// • The node catches the refusal and folds it into a step failure
// (`create_record(deal) failed: Unknown field 'stagee' on object 'deal'`),
// so the RUN fails — far from the authoring mistake, which is exactly why
// an author-time rule is still worth having.
//
// ⚠️ Until #13858 this block described the pre-#8682 driver split (SQL rejected
// the statement, a schemaless datasource persisted the stray key). That is
// retired, not merely restated: the severity below is unchanged because neither
// the old outcome nor the new one is ever "the rest still works".
//
// No outcome is "the rest still works". That is the same call
// `validate-searchable-fields` makes for a stale entry and
Expand DownExpand Up@@ -290,12 +294,18 @@ export function validateFlowNodeWrites(stack: AnyRec): FlowNodeWriteFinding[] {
where: `flow "${flowName}" › ${nodeWhere}`,
path: `${nodePath}.config.fields.${fieldName}`,
message:
`${node.type} writes '${fieldName}', but object '${objectName}' declares no such field. Nothing ` +
`between the node and storage removes the key: on a SQL datasource the driver rejects the whole ` +
`statement ('no such column'), so the correctly named fields in this same payload never land ` +
`either${
// [#13858] The node hands `fields` to the data engine directly
// (`data.insert` / `data.update` in service-automation's
// crud-nodes), so it is a CALLER payload and the #8682/#8738
// declared-field door refuses it before any datasource is reached.
// Measured on driver-sql and driver-memory alike.
`${node.type} writes '${fieldName}', but object '${objectName}' declares no such field. The ` +
`node hands its fields map to the engine as an ordinary caller payload, so the ` +
`declared-field door REFUSES the whole write — INVALID_FIELD / 400, identically on every ` +
`datasource, before any statement is built. The correctly named fields in this same payload ` +
`never land either${
node.type === 'create_record' ? ' and the record is never created at all' : ''
}; on a schemaless one the stray key is persisted into a column no read surface returns.`,
}, and the step fails the run.`,
hint: fixHint(fieldName, [...known]),
});
}
Expand Down
37 changes: 37 additions & 0 deletions packages/lint/src/validate-hook-body-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -246,6 +246,43 @@ describe('validateHookBodyWrites — ctx.api writes', () => {
expect(findings[0].hint).toContain("'email'");
});

// [#13858] The message is the whole product of an advisory rule, so the
// sentence IS the deliverable. It used to promise a driver-dependent outcome
// ("on a SQL driver … a driver-level error; on a schemaless driver … the
// stray key is persisted"), which has not been true for this path since
// #8682/#8738: `ctx.api` is a ScopedContext over the running engine, so the
// payload is CALLER-supplied and the declared-field door refuses it first.
//
// Measured before this text was written — real QuickJS sandbox, real hook
// body, real ObjectQL, real driver-sql (better-sqlite3) AND real
// driver-memory: both families answered `INVALID_FIELD` / 400, "Unknown field
// 'stagee' on object 'deal'", the target row was untouched, and the memory
// family stored no shadow column. Same door the caller-payload half of
// `undeclared-field-write-driver-split.integration.test.ts` pins.
it('states the measured refusal — INVALID_FIELD / 400 on every driver — and no driver split', () => {
const [finding] = validateHookBodyWrites(
stackWith("await ctx.api.object('crm_deal').update({ id, stag: 'won' });"),
);

// What the author actually gets, in the vocabulary #13657 landed for the
// `ctx.input` sibling one branch over — one door, one phrasing.
expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every driver');
expect(finding.message).toContain('before any statement is built');
// Why it is refused there rather than by a driver: the payload is a
// CALLER's, which is the fact the whole rewrite turns on.
expect(finding.message).toContain('ordinary CALLER write');
// ...and the blast radius that makes an author-time rule worth having.
expect(finding.message).toContain('fails the operation that triggered the hook');

// The retired claim, in both halves. Neither may come back without a
// measurement saying it should.
expect(finding.message).not.toMatch(/driver-level error/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/write-path validator skips/);
});

it('checks updateById payloads at argument 1, not 0', () => {
const findings = validateHookBodyWrites(
stackWith("await ctx.api.object('crm_deal').updateById(ctx.input.id, { stag: 'won' });"),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
53 changes: 53 additions & 0 deletions .changeset/write-set-messages-drop-driver-split.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/lint": patch
---

fix(lint): the three write-set rule messages now state the refusal authors actually get, not a retired driver split (#13858)

Message text only. Rule ids, severities, match sets and hints are untouched, and
no finding changes shape — but a lint's own header states why the prose is
governed: *"a lint that misdescribes the failure it is warning about teaches the
wrong debugging instinct"*. These three sentences did.

`validate-hook-body-writes` (the `ctx.api` branch), `validate-action-body-writes`
and `validate-flow-node-writes` all told the author that an undeclared write has
a **driver-dependent** outcome:

> 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

For the paths those three rules judge, that has not been true since the
declared-field door landed (#8682 insert, #8738 update). All three describe a
write whose payload is **caller-supplied**, not a mutation of an in-flight
`ctx.input`: `ctx.api` is a `ScopedContext` over the running engine, and a flow
node hands its `fields` map to the data engine directly. The door refuses a
caller-named undeclared key from the object's field map **before any statement is
built**, so no driver is reached and there is no split to observe.

Measured before the prose was rewritten — all three paths, both driver families,
through a real QuickJS sandbox, a real `ObjectQL` engine, the real
`AutomationEngine` with the real builtin CRUD node executors, real
`@objectstack/driver-sql` (better-sqlite3) and real `@objectstack/driver-memory`:

| path | driver-sql | driver-memory |
|---|---|---|
| hook body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
| action body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
| flow `create_record` / `update_record` `fields` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |

Every run answered `Unknown field 'stagee' on object 'deal'`; nothing was stored
on either family, and the schemaless family kept **no** shadow column — the half
the old message promised and the runtime no longer delivers.

The three messages now name that refusal in the vocabulary the `ctx.input`
sibling landed with (`REFUSED at run time — INVALID_FIELD / 400, identically on
every driver`), say why the door and not a driver answers, and keep each path's
own blast radius: the hook refusal fails the operation that triggered the hook,
the action refusal fails the action, and the flow node's refusal is whole — the
correctly named fields in the same payload never land either, `create_record`
never creates the row, and the step fails the run. That last clause is why the
flow rule still gates at `error`; the severity is unchanged.

`unprovisionedAnchorWriteConsequence()` in the same files is **untouched**: an
ADR-0015 external object's injected anchor *is* declared in the registered
schema, so it passes the door by construction and the remote database really is
what refuses it. That message was already correct.
29 changes: 29 additions & 0 deletions packages/lint/src/validate-action-body-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,35 @@ describe('validateActionBodyWrites — ctx.api writes', () => {
expect(findings[0].hint).toContain("'discount_total'");
});

// [#13858] The same rewrite as the hook sibling, from the same measurement:
// real QuickJS sandbox, a real L2 ACTION body run through
// `actionBodyRunnerFactory`, a real ObjectQL engine, real driver-sql
// (better-sqlite3) AND real driver-memory. Both families answered
// `INVALID_FIELD` / 400, "Unknown field 'stagee' on object 'deal'"; the
// target row was untouched and the memory family stored no shadow column.
// The old text promised a driver-level error on SQL and a persisted stray
// key on schemaless — neither happens on this path, and has not since
// #8682/#8738 put the declared-field door ahead of any statement.
it('states the measured refusal — INVALID_FIELD / 400 on every driver — and no driver split', () => {
const [finding] = validateActionBodyWrites(
stackWith("await ctx.api.object('crm_deal').update({ discont_total: 0 });"),
);

expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every driver');
expect(finding.message).toContain('before any statement is built');
// The reason the door — not a driver — is what answers.
expect(finding.message).toContain('ordinary CALLER write');
// The action-side blast radius, the one word that differs from the hook
// sibling's sentence. Pinned so a future sweep cannot flatten the two.
expect(finding.message).toContain('fails the action');

expect(finding.message).not.toMatch(/driver-level error/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/write-path validator skips/);
});

it('checks insert/create/update payloads (argument 0) and updateById at argument 1', () => {
const findings = validateActionBodyWrites(
stackWith(
Expand Down
30 changes: 20 additions & 10 deletions packages/lint/src/validate-action-body-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,18 @@
// `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 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.
// writes a field the target object never declares is refused at run time, far
// from the authoring mistake. [#13858] That refusal is NOT driver-dependent,
// and the message says so: this rule judges exactly one shape,
// `ctx.api.object('<literal>').insert|create|update|updateById(…)`, and
// `ctx.api` is a ScopedContext over the running engine, so the payload is
// CALLER-supplied and the declared-field door (#8682 insert, #8738 update)
// refuses it — `INVALID_FIELD` / 400, identically on driver-sql and
// driver-memory, before any statement is built. Measured on both families
// through the real sandbox and the real engine; the caller-payload half of
// that door is pinned in
// `undeclared-field-write-driver-split.integration.test.ts`. The hook rule
// alone left half the surface uncovered, which is why this file exists.
//
// ─── What does NOT carry over ───────────────────────────────────────────────
//
Expand DownExpand Up@@ -429,9 +434,14 @@ 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 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).`,
// [#13858] Same door, same measurement as the hook sibling — ctx.api
// is a ScopedContext over the running engine, so this payload is
// CALLER-supplied and #8682/#8738 refuse it before any driver.
`object '${w.object}' declares no such field. ctx.api is a scoped handle on the running ` +
`engine, so the payload arrives as an ordinary CALLER write and the declared-field door ` +
`REFUSES it at run time — INVALID_FIELD / 400, identically on every driver (#4271), before ` +
`any statement is built. The write lands nothing, and the refusal escapes the body and ` +
`fails the action.`,
hint: fixHint(w.field, [...known]),
});
}
Expand Down
35 changes: 35 additions & 0 deletions packages/lint/src/validate-flow-node-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -181,6 +181,41 @@ describe('validateFlowNodeWrites', () => {
expect(findings[0].hint).toMatch(/Did you mean (one of: )?'stage'/);
});

// [#13858] This rule GATES (severity `error`), so its message is what an
// author reads while their build is refused — the one place a wrong causal
// story costs the most. It used to say "on a SQL datasource the driver
// rejects the whole statement ('no such column') … on a schemaless one the
// stray key is persisted". Measured through the real AutomationEngine, the
// real builtin CRUD nodes, a real ObjectQL engine and BOTH families
// (driver-sql on better-sqlite3, driver-memory): neither happens. Both
// answered `INVALID_FIELD` / 400, "Unknown field 'stagee' on object 'deal'",
// the node folded that into `create_record(deal) failed: …`, the run failed,
// and nothing was stored on either family — no row on create, an untouched
// row and no shadow column on update.
it('states the measured refusal — INVALID_FIELD / 400 on every datasource — and no driver split', () => {
const [finding] = validateFlowNodeWrites({
objects: [dealObject],
flows: [flowWith({ stagee: 'won' })],
});

expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every datasource');
expect(finding.message).toContain('before any statement is built');
// Why the door answers and not a datasource: the node hands `fields`
// straight to the data engine, so it is a caller payload.
expect(finding.message).toContain('ordinary caller payload');
// The severity's own justification, unchanged by the rewrite and still
// stated: the refusal is WHOLE, so correctly named siblings are lost too.
expect(finding.message).toContain('never land either');
expect(finding.message).toContain('the step fails the run');

// The retired driver split, both halves.
expect(finding.message).not.toMatch(/no such column/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/Nothing between the node and storage/);
});

it('flags every unknown key in one node, and only those', () => {
const findings = validateFlowNodeWrites({
objects: [dealObject],
Expand Down
60 changes: 35 additions & 25 deletions packages/lint/src/validate-flow-node-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,27 +26,31 @@
//
// And the runtime consequence is not the benign "consumer skips the unknown
// name and does the rest" that keeps `page-field-unknown` / `form-field-unknown`
// advisory. Nothing between the node and storage removes the key: the flow
// executor calls the data engine directly (bypassing the metadata-protocol
// ingress, which strips `readonly` — not unknown — keys anyway), the engine's
// write paths strip only readonly/readonlyWhen, and the SQL driver's
// `formatInput` / `applyWriteColumnMap` pass an unrecognized key straight
// through (`m[k] ?? k`). Every branch below was measured, not inferred:
// advisory. The flow executor calls the data engine directly (`data.insert` /
// `data.update` in service-automation's `builtin/crud-nodes.ts`, bypassing the
// metadata-protocol ingress), so the node's `fields` map arrives as an ordinary
// CALLER payload — and [#13858] the declared-field door (#8682 insert, #8738
// update) refuses a caller-named undeclared key from the object's field map
// before any statement is built. Every branch below was measured through the
// real AutomationEngine, the real builtin CRUD nodes, the real engine and BOTH
// driver families (driver-sql on better-sqlite3, driver-memory), not inferred:
//
// • Through the engine, an undeclared key reaches `driver.update` /
// `driver.create` verbatim, alongside the audit stamps.
// • On SQLite/knex an UPDATE becomes `update "deal" set "name" = 'n2',
// "stagee" = 'won' … → no such column: stagee`. The statement is rejected
// WHOLE: `name` — spelled correctly, in the same payload — does not land
// either, and the step fails with a driver error naming a column, far from
// the authoring mistake.
// • An INSERT fails the same way (`table deal has no column named stagee`),
// and one notch harder: the row is never created at all, so every later
// node that expected `{<node>.id}` is working from a record that does not
// exist.
// • On a schemaless datasource (memory, MongoDB) nothing rejects it, so the
// stray key is persisted into a column the object never declares — where no
// schema-driven read surface will return it.
// • Both families answer identically — `INVALID_FIELD` / 400, "Unknown field
// 'stagee' on object 'deal'". No driver is reached, so there is no split to
// observe.
// • The write is refused WHOLE: `name` — spelled correctly, in the same
// payload — does not land either.
// • On `create_record` the row is never created at all, so every later node
// that expected `{<node>.id}` is working from a record that does not exist.
// • The node catches the refusal and folds it into a step failure
// (`create_record(deal) failed: Unknown field 'stagee' on object 'deal'`),
// so the RUN fails — far from the authoring mistake, which is exactly why
// an author-time rule is still worth having.
//
// ⚠️ Until #13858 this block described the pre-#8682 driver split (SQL rejected
// the statement, a schemaless datasource persisted the stray key). That is
// retired, not merely restated: the severity below is unchanged because neither
// the old outcome nor the new one is ever "the rest still works".
//
// No outcome is "the rest still works". That is the same call
// `validate-searchable-fields` makes for a stale entry and
Expand DownExpand Up@@ -290,12 +294,18 @@ export function validateFlowNodeWrites(stack: AnyRec): FlowNodeWriteFinding[] {
where: `flow "${flowName}" › ${nodeWhere}`,
path: `${nodePath}.config.fields.${fieldName}`,
message:
`${node.type} writes '${fieldName}', but object '${objectName}' declares no such field. Nothing ` +
`between the node and storage removes the key: on a SQL datasource the driver rejects the whole ` +
`statement ('no such column'), so the correctly named fields in this same payload never land ` +
`either${
// [#13858] The node hands `fields` to the data engine directly
// (`data.insert` / `data.update` in service-automation's
// crud-nodes), so it is a CALLER payload and the #8682/#8738
// declared-field door refuses it before any datasource is reached.
// Measured on driver-sql and driver-memory alike.
`${node.type} writes '${fieldName}', but object '${objectName}' declares no such field. The ` +
`node hands its fields map to the engine as an ordinary caller payload, so the ` +
`declared-field door REFUSES the whole write — INVALID_FIELD / 400, identically on every ` +
`datasource, before any statement is built. The correctly named fields in this same payload ` +
`never land either${
node.type === 'create_record' ? ' and the record is never created at all' : ''
}; on a schemaless one the stray key is persisted into a column no read surface returns.`,
}, and the step fails the run.`,
hint: fixHint(fieldName, [...known]),
});
}
Expand Down
37 changes: 37 additions & 0 deletions packages/lint/src/validate-hook-body-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -246,6 +246,43 @@ describe('validateHookBodyWrites — ctx.api writes', () => {
expect(findings[0].hint).toContain("'email'");
});

// [#13858] The message is the whole product of an advisory rule, so the
// sentence IS the deliverable. It used to promise a driver-dependent outcome
// ("on a SQL driver … a driver-level error; on a schemaless driver … the
// stray key is persisted"), which has not been true for this path since
// #8682/#8738: `ctx.api` is a ScopedContext over the running engine, so the
// payload is CALLER-supplied and the declared-field door refuses it first.
//
// Measured before this text was written — real QuickJS sandbox, real hook
// body, real ObjectQL, real driver-sql (better-sqlite3) AND real
// driver-memory: both families answered `INVALID_FIELD` / 400, "Unknown field
// 'stagee' on object 'deal'", the target row was untouched, and the memory
// family stored no shadow column. Same door the caller-payload half of
// `undeclared-field-write-driver-split.integration.test.ts` pins.
it('states the measured refusal — INVALID_FIELD / 400 on every driver — and no driver split', () => {
const [finding] = validateHookBodyWrites(
stackWith("await ctx.api.object('crm_deal').update({ id, stag: 'won' });"),
);

// What the author actually gets, in the vocabulary #13657 landed for the
// `ctx.input` sibling one branch over — one door, one phrasing.
expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every driver');
expect(finding.message).toContain('before any statement is built');
// Why it is refused there rather than by a driver: the payload is a
// CALLER's, which is the fact the whole rewrite turns on.
expect(finding.message).toContain('ordinary CALLER write');
// ...and the blast radius that makes an author-time rule worth having.
expect(finding.message).toContain('fails the operation that triggered the hook');

// The retired claim, in both halves. Neither may come back without a
// measurement saying it should.
expect(finding.message).not.toMatch(/driver-level error/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/write-path validator skips/);
});

it('checks updateById payloads at argument 1, not 0', () => {
const findings = validateHookBodyWrites(
stackWith("await ctx.api.object('crm_deal').updateById(ctx.input.id, { stag: 'won' });"),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
53 changes: 53 additions & 0 deletions .changeset/write-set-messages-drop-driver-split.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/lint": patch
---

fix(lint): the three write-set rule messages now state the refusal authors actually get, not a retired driver split (#13858)

Message text only. Rule ids, severities, match sets and hints are untouched, and
no finding changes shape — but a lint's own header states why the prose is
governed: *"a lint that misdescribes the failure it is warning about teaches the
wrong debugging instinct"*. These three sentences did.

`validate-hook-body-writes` (the `ctx.api` branch), `validate-action-body-writes`
and `validate-flow-node-writes` all told the author that an undeclared write has
a **driver-dependent** outcome:

> 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

For the paths those three rules judge, that has not been true since the
declared-field door landed (#8682 insert, #8738 update). All three describe a
write whose payload is **caller-supplied**, not a mutation of an in-flight
`ctx.input`: `ctx.api` is a `ScopedContext` over the running engine, and a flow
node hands its `fields` map to the data engine directly. The door refuses a
caller-named undeclared key from the object's field map **before any statement is
built**, so no driver is reached and there is no split to observe.

Measured before the prose was rewritten — all three paths, both driver families,
through a real QuickJS sandbox, a real `ObjectQL` engine, the real
`AutomationEngine` with the real builtin CRUD node executors, real
`@objectstack/driver-sql` (better-sqlite3) and real `@objectstack/driver-memory`:

| path | driver-sql | driver-memory |
|---|---|---|
| hook body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
| action body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
| flow `create_record` / `update_record` `fields` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |

Every run answered `Unknown field 'stagee' on object 'deal'`; nothing was stored
on either family, and the schemaless family kept **no** shadow column — the half
the old message promised and the runtime no longer delivers.

The three messages now name that refusal in the vocabulary the `ctx.input`
sibling landed with (`REFUSED at run time — INVALID_FIELD / 400, identically on
every driver`), say why the door and not a driver answers, and keep each path's
own blast radius: the hook refusal fails the operation that triggered the hook,
the action refusal fails the action, and the flow node's refusal is whole — the
correctly named fields in the same payload never land either, `create_record`
never creates the row, and the step fails the run. That last clause is why the
flow rule still gates at `error`; the severity is unchanged.

`unprovisionedAnchorWriteConsequence()` in the same files is **untouched**: an
ADR-0015 external object's injected anchor *is* declared in the registered
schema, so it passes the door by construction and the remote database really is
what refuses it. That message was already correct.
29 changes: 29 additions & 0 deletions packages/lint/src/validate-action-body-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,35 @@ describe('validateActionBodyWrites — ctx.api writes', () => {
expect(findings[0].hint).toContain("'discount_total'");
});

// [#13858] The same rewrite as the hook sibling, from the same measurement:
// real QuickJS sandbox, a real L2 ACTION body run through
// `actionBodyRunnerFactory`, a real ObjectQL engine, real driver-sql
// (better-sqlite3) AND real driver-memory. Both families answered
// `INVALID_FIELD` / 400, "Unknown field 'stagee' on object 'deal'"; the
// target row was untouched and the memory family stored no shadow column.
// The old text promised a driver-level error on SQL and a persisted stray
// key on schemaless — neither happens on this path, and has not since
// #8682/#8738 put the declared-field door ahead of any statement.
it('states the measured refusal — INVALID_FIELD / 400 on every driver — and no driver split', () => {
const [finding] = validateActionBodyWrites(
stackWith("await ctx.api.object('crm_deal').update({ discont_total: 0 });"),
);

expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every driver');
expect(finding.message).toContain('before any statement is built');
// The reason the door — not a driver — is what answers.
expect(finding.message).toContain('ordinary CALLER write');
// The action-side blast radius, the one word that differs from the hook
// sibling's sentence. Pinned so a future sweep cannot flatten the two.
expect(finding.message).toContain('fails the action');

expect(finding.message).not.toMatch(/driver-level error/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/write-path validator skips/);
});

it('checks insert/create/update payloads (argument 0) and updateById at argument 1', () => {
const findings = validateActionBodyWrites(
stackWith(
Expand Down
30 changes: 20 additions & 10 deletions packages/lint/src/validate-action-body-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,18 @@
// `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 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.
// writes a field the target object never declares is refused at run time, far
// from the authoring mistake. [#13858] That refusal is NOT driver-dependent,
// and the message says so: this rule judges exactly one shape,
// `ctx.api.object('<literal>').insert|create|update|updateById(…)`, and
// `ctx.api` is a ScopedContext over the running engine, so the payload is
// CALLER-supplied and the declared-field door (#8682 insert, #8738 update)
// refuses it — `INVALID_FIELD` / 400, identically on driver-sql and
// driver-memory, before any statement is built. Measured on both families
// through the real sandbox and the real engine; the caller-payload half of
// that door is pinned in
// `undeclared-field-write-driver-split.integration.test.ts`. The hook rule
// alone left half the surface uncovered, which is why this file exists.
//
// ─── What does NOT carry over ───────────────────────────────────────────────
//
Expand DownExpand Up@@ -429,9 +434,14 @@ 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 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).`,
// [#13858] Same door, same measurement as the hook sibling — ctx.api
// is a ScopedContext over the running engine, so this payload is
// CALLER-supplied and #8682/#8738 refuse it before any driver.
`object '${w.object}' declares no such field. ctx.api is a scoped handle on the running ` +
`engine, so the payload arrives as an ordinary CALLER write and the declared-field door ` +
`REFUSES it at run time — INVALID_FIELD / 400, identically on every driver (#4271), before ` +
`any statement is built. The write lands nothing, and the refusal escapes the body and ` +
`fails the action.`,
hint: fixHint(w.field, [...known]),
});
}
Expand Down
35 changes: 35 additions & 0 deletions packages/lint/src/validate-flow-node-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -181,6 +181,41 @@ describe('validateFlowNodeWrites', () => {
expect(findings[0].hint).toMatch(/Did you mean (one of: )?'stage'/);
});

// [#13858] This rule GATES (severity `error`), so its message is what an
// author reads while their build is refused — the one place a wrong causal
// story costs the most. It used to say "on a SQL datasource the driver
// rejects the whole statement ('no such column') … on a schemaless one the
// stray key is persisted". Measured through the real AutomationEngine, the
// real builtin CRUD nodes, a real ObjectQL engine and BOTH families
// (driver-sql on better-sqlite3, driver-memory): neither happens. Both
// answered `INVALID_FIELD` / 400, "Unknown field 'stagee' on object 'deal'",
// the node folded that into `create_record(deal) failed: …`, the run failed,
// and nothing was stored on either family — no row on create, an untouched
// row and no shadow column on update.
it('states the measured refusal — INVALID_FIELD / 400 on every datasource — and no driver split', () => {
const [finding] = validateFlowNodeWrites({
objects: [dealObject],
flows: [flowWith({ stagee: 'won' })],
});

expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every datasource');
expect(finding.message).toContain('before any statement is built');
// Why the door answers and not a datasource: the node hands `fields`
// straight to the data engine, so it is a caller payload.
expect(finding.message).toContain('ordinary caller payload');
// The severity's own justification, unchanged by the rewrite and still
// stated: the refusal is WHOLE, so correctly named siblings are lost too.
expect(finding.message).toContain('never land either');
expect(finding.message).toContain('the step fails the run');

// The retired driver split, both halves.
expect(finding.message).not.toMatch(/no such column/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/Nothing between the node and storage/);
});

it('flags every unknown key in one node, and only those', () => {
const findings = validateFlowNodeWrites({
objects: [dealObject],
Expand Down
60 changes: 35 additions & 25 deletions packages/lint/src/validate-flow-node-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,27 +26,31 @@
//
// And the runtime consequence is not the benign "consumer skips the unknown
// name and does the rest" that keeps `page-field-unknown` / `form-field-unknown`
// advisory. Nothing between the node and storage removes the key: the flow
// executor calls the data engine directly (bypassing the metadata-protocol
// ingress, which strips `readonly` — not unknown — keys anyway), the engine's
// write paths strip only readonly/readonlyWhen, and the SQL driver's
// `formatInput` / `applyWriteColumnMap` pass an unrecognized key straight
// through (`m[k] ?? k`). Every branch below was measured, not inferred:
// advisory. The flow executor calls the data engine directly (`data.insert` /
// `data.update` in service-automation's `builtin/crud-nodes.ts`, bypassing the
// metadata-protocol ingress), so the node's `fields` map arrives as an ordinary
// CALLER payload — and [#13858] the declared-field door (#8682 insert, #8738
// update) refuses a caller-named undeclared key from the object's field map
// before any statement is built. Every branch below was measured through the
// real AutomationEngine, the real builtin CRUD nodes, the real engine and BOTH
// driver families (driver-sql on better-sqlite3, driver-memory), not inferred:
//
// • Through the engine, an undeclared key reaches `driver.update` /
// `driver.create` verbatim, alongside the audit stamps.
// • On SQLite/knex an UPDATE becomes `update "deal" set "name" = 'n2',
// "stagee" = 'won' … → no such column: stagee`. The statement is rejected
// WHOLE: `name` — spelled correctly, in the same payload — does not land
// either, and the step fails with a driver error naming a column, far from
// the authoring mistake.
// • An INSERT fails the same way (`table deal has no column named stagee`),
// and one notch harder: the row is never created at all, so every later
// node that expected `{<node>.id}` is working from a record that does not
// exist.
// • On a schemaless datasource (memory, MongoDB) nothing rejects it, so the
// stray key is persisted into a column the object never declares — where no
// schema-driven read surface will return it.
// • Both families answer identically — `INVALID_FIELD` / 400, "Unknown field
// 'stagee' on object 'deal'". No driver is reached, so there is no split to
// observe.
// • The write is refused WHOLE: `name` — spelled correctly, in the same
// payload — does not land either.
// • On `create_record` the row is never created at all, so every later node
// that expected `{<node>.id}` is working from a record that does not exist.
// • The node catches the refusal and folds it into a step failure
// (`create_record(deal) failed: Unknown field 'stagee' on object 'deal'`),
// so the RUN fails — far from the authoring mistake, which is exactly why
// an author-time rule is still worth having.
//
// ⚠️ Until #13858 this block described the pre-#8682 driver split (SQL rejected
// the statement, a schemaless datasource persisted the stray key). That is
// retired, not merely restated: the severity below is unchanged because neither
// the old outcome nor the new one is ever "the rest still works".
//
// No outcome is "the rest still works". That is the same call
// `validate-searchable-fields` makes for a stale entry and
Expand DownExpand Up@@ -290,12 +294,18 @@ export function validateFlowNodeWrites(stack: AnyRec): FlowNodeWriteFinding[] {
where: `flow "${flowName}" › ${nodeWhere}`,
path: `${nodePath}.config.fields.${fieldName}`,
message:
`${node.type} writes '${fieldName}', but object '${objectName}' declares no such field. Nothing ` +
`between the node and storage removes the key: on a SQL datasource the driver rejects the whole ` +
`statement ('no such column'), so the correctly named fields in this same payload never land ` +
`either${
// [#13858] The node hands `fields` to the data engine directly
// (`data.insert` / `data.update` in service-automation's
// crud-nodes), so it is a CALLER payload and the #8682/#8738
// declared-field door refuses it before any datasource is reached.
// Measured on driver-sql and driver-memory alike.
`${node.type} writes '${fieldName}', but object '${objectName}' declares no such field. The ` +
`node hands its fields map to the engine as an ordinary caller payload, so the ` +
`declared-field door REFUSES the whole write — INVALID_FIELD / 400, identically on every ` +
`datasource, before any statement is built. The correctly named fields in this same payload ` +
`never land either${
node.type === 'create_record' ? ' and the record is never created at all' : ''
}; on a schemaless one the stray key is persisted into a column no read surface returns.`,
}, and the step fails the run.`,
hint: fixHint(fieldName, [...known]),
});
}
Expand Down
37 changes: 37 additions & 0 deletions packages/lint/src/validate-hook-body-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -246,6 +246,43 @@ describe('validateHookBodyWrites — ctx.api writes', () => {
expect(findings[0].hint).toContain("'email'");
});

// [#13858] The message is the whole product of an advisory rule, so the
// sentence IS the deliverable. It used to promise a driver-dependent outcome
// ("on a SQL driver … a driver-level error; on a schemaless driver … the
// stray key is persisted"), which has not been true for this path since
// #8682/#8738: `ctx.api` is a ScopedContext over the running engine, so the
// payload is CALLER-supplied and the declared-field door refuses it first.
//
// Measured before this text was written — real QuickJS sandbox, real hook
// body, real ObjectQL, real driver-sql (better-sqlite3) AND real
// driver-memory: both families answered `INVALID_FIELD` / 400, "Unknown field
// 'stagee' on object 'deal'", the target row was untouched, and the memory
// family stored no shadow column. Same door the caller-payload half of
// `undeclared-field-write-driver-split.integration.test.ts` pins.
it('states the measured refusal — INVALID_FIELD / 400 on every driver — and no driver split', () => {
const [finding] = validateHookBodyWrites(
stackWith("await ctx.api.object('crm_deal').update({ id, stag: 'won' });"),
);

// What the author actually gets, in the vocabulary #13657 landed for the
// `ctx.input` sibling one branch over — one door, one phrasing.
expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every driver');
expect(finding.message).toContain('before any statement is built');
// Why it is refused there rather than by a driver: the payload is a
// CALLER's, which is the fact the whole rewrite turns on.
expect(finding.message).toContain('ordinary CALLER write');
// ...and the blast radius that makes an author-time rule worth having.
expect(finding.message).toContain('fails the operation that triggered the hook');

// The retired claim, in both halves. Neither may come back without a
// measurement saying it should.
expect(finding.message).not.toMatch(/driver-level error/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/write-path validator skips/);
});

it('checks updateById payloads at argument 1, not 0', () => {
const findings = validateHookBodyWrites(
stackWith("await ctx.api.object('crm_deal').updateById(ctx.input.id, { stag: 'won' });"),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
53 changes: 53 additions & 0 deletions .changeset/write-set-messages-drop-driver-split.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/lint": patch
---

fix(lint): the three write-set rule messages now state the refusal authors actually get, not a retired driver split (#13858)

Message text only. Rule ids, severities, match sets and hints are untouched, and
no finding changes shape — but a lint's own header states why the prose is
governed: *"a lint that misdescribes the failure it is warning about teaches the
wrong debugging instinct"*. These three sentences did.

`validate-hook-body-writes` (the `ctx.api` branch), `validate-action-body-writes`
and `validate-flow-node-writes` all told the author that an undeclared write has
a **driver-dependent** outcome:

> 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

For the paths those three rules judge, that has not been true since the
declared-field door landed (#8682 insert, #8738 update). All three describe a
write whose payload is **caller-supplied**, not a mutation of an in-flight
`ctx.input`: `ctx.api` is a `ScopedContext` over the running engine, and a flow
node hands its `fields` map to the data engine directly. The door refuses a
caller-named undeclared key from the object's field map **before any statement is
built**, so no driver is reached and there is no split to observe.

Measured before the prose was rewritten — all three paths, both driver families,
through a real QuickJS sandbox, a real `ObjectQL` engine, the real
`AutomationEngine` with the real builtin CRUD node executors, real
`@objectstack/driver-sql` (better-sqlite3) and real `@objectstack/driver-memory`:

| path | driver-sql | driver-memory |
|---|---|---|
| hook body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
| action body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
| flow `create_record` / `update_record` `fields` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |

Every run answered `Unknown field 'stagee' on object 'deal'`; nothing was stored
on either family, and the schemaless family kept **no** shadow column — the half
the old message promised and the runtime no longer delivers.

The three messages now name that refusal in the vocabulary the `ctx.input`
sibling landed with (`REFUSED at run time — INVALID_FIELD / 400, identically on
every driver`), say why the door and not a driver answers, and keep each path's
own blast radius: the hook refusal fails the operation that triggered the hook,
the action refusal fails the action, and the flow node's refusal is whole — the
correctly named fields in the same payload never land either, `create_record`
never creates the row, and the step fails the run. That last clause is why the
flow rule still gates at `error`; the severity is unchanged.

`unprovisionedAnchorWriteConsequence()` in the same files is **untouched**: an
ADR-0015 external object's injected anchor *is* declared in the registered
schema, so it passes the door by construction and the remote database really is
what refuses it. That message was already correct.
29 changes: 29 additions & 0 deletions packages/lint/src/validate-action-body-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,35 @@ describe('validateActionBodyWrites — ctx.api writes', () => {
expect(findings[0].hint).toContain("'discount_total'");
});

// [#13858] The same rewrite as the hook sibling, from the same measurement:
// real QuickJS sandbox, a real L2 ACTION body run through
// `actionBodyRunnerFactory`, a real ObjectQL engine, real driver-sql
// (better-sqlite3) AND real driver-memory. Both families answered
// `INVALID_FIELD` / 400, "Unknown field 'stagee' on object 'deal'"; the
// target row was untouched and the memory family stored no shadow column.
// The old text promised a driver-level error on SQL and a persisted stray
// key on schemaless — neither happens on this path, and has not since
// #8682/#8738 put the declared-field door ahead of any statement.
it('states the measured refusal — INVALID_FIELD / 400 on every driver — and no driver split', () => {
const [finding] = validateActionBodyWrites(
stackWith("await ctx.api.object('crm_deal').update({ discont_total: 0 });"),
);

expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every driver');
expect(finding.message).toContain('before any statement is built');
// The reason the door — not a driver — is what answers.
expect(finding.message).toContain('ordinary CALLER write');
// The action-side blast radius, the one word that differs from the hook
// sibling's sentence. Pinned so a future sweep cannot flatten the two.
expect(finding.message).toContain('fails the action');

expect(finding.message).not.toMatch(/driver-level error/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/write-path validator skips/);
});

it('checks insert/create/update payloads (argument 0) and updateById at argument 1', () => {
const findings = validateActionBodyWrites(
stackWith(
Expand Down
30 changes: 20 additions & 10 deletions packages/lint/src/validate-action-body-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,18 @@
// `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 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.
// writes a field the target object never declares is refused at run time, far
// from the authoring mistake. [#13858] That refusal is NOT driver-dependent,
// and the message says so: this rule judges exactly one shape,
// `ctx.api.object('<literal>').insert|create|update|updateById(…)`, and
// `ctx.api` is a ScopedContext over the running engine, so the payload is
// CALLER-supplied and the declared-field door (#8682 insert, #8738 update)
// refuses it — `INVALID_FIELD` / 400, identically on driver-sql and
// driver-memory, before any statement is built. Measured on both families
// through the real sandbox and the real engine; the caller-payload half of
// that door is pinned in
// `undeclared-field-write-driver-split.integration.test.ts`. The hook rule
// alone left half the surface uncovered, which is why this file exists.
//
// ─── What does NOT carry over ───────────────────────────────────────────────
//
Expand DownExpand Up@@ -429,9 +434,14 @@ 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 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).`,
// [#13858] Same door, same measurement as the hook sibling — ctx.api
// is a ScopedContext over the running engine, so this payload is
// CALLER-supplied and #8682/#8738 refuse it before any driver.
`object '${w.object}' declares no such field. ctx.api is a scoped handle on the running ` +
`engine, so the payload arrives as an ordinary CALLER write and the declared-field door ` +
`REFUSES it at run time — INVALID_FIELD / 400, identically on every driver (#4271), before ` +
`any statement is built. The write lands nothing, and the refusal escapes the body and ` +
`fails the action.`,
hint: fixHint(w.field, [...known]),
});
}
Expand Down
35 changes: 35 additions & 0 deletions packages/lint/src/validate-flow-node-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -181,6 +181,41 @@ describe('validateFlowNodeWrites', () => {
expect(findings[0].hint).toMatch(/Did you mean (one of: )?'stage'/);
});

// [#13858] This rule GATES (severity `error`), so its message is what an
// author reads while their build is refused — the one place a wrong causal
// story costs the most. It used to say "on a SQL datasource the driver
// rejects the whole statement ('no such column') … on a schemaless one the
// stray key is persisted". Measured through the real AutomationEngine, the
// real builtin CRUD nodes, a real ObjectQL engine and BOTH families
// (driver-sql on better-sqlite3, driver-memory): neither happens. Both
// answered `INVALID_FIELD` / 400, "Unknown field 'stagee' on object 'deal'",
// the node folded that into `create_record(deal) failed: …`, the run failed,
// and nothing was stored on either family — no row on create, an untouched
// row and no shadow column on update.
it('states the measured refusal — INVALID_FIELD / 400 on every datasource — and no driver split', () => {
const [finding] = validateFlowNodeWrites({
objects: [dealObject],
flows: [flowWith({ stagee: 'won' })],
});

expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every datasource');
expect(finding.message).toContain('before any statement is built');
// Why the door answers and not a datasource: the node hands `fields`
// straight to the data engine, so it is a caller payload.
expect(finding.message).toContain('ordinary caller payload');
// The severity's own justification, unchanged by the rewrite and still
// stated: the refusal is WHOLE, so correctly named siblings are lost too.
expect(finding.message).toContain('never land either');
expect(finding.message).toContain('the step fails the run');

// The retired driver split, both halves.
expect(finding.message).not.toMatch(/no such column/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/Nothing between the node and storage/);
});

it('flags every unknown key in one node, and only those', () => {
const findings = validateFlowNodeWrites({
objects: [dealObject],
Expand Down
60 changes: 35 additions & 25 deletions packages/lint/src/validate-flow-node-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,27 +26,31 @@
//
// And the runtime consequence is not the benign "consumer skips the unknown
// name and does the rest" that keeps `page-field-unknown` / `form-field-unknown`
// advisory. Nothing between the node and storage removes the key: the flow
// executor calls the data engine directly (bypassing the metadata-protocol
// ingress, which strips `readonly` — not unknown — keys anyway), the engine's
// write paths strip only readonly/readonlyWhen, and the SQL driver's
// `formatInput` / `applyWriteColumnMap` pass an unrecognized key straight
// through (`m[k] ?? k`). Every branch below was measured, not inferred:
// advisory. The flow executor calls the data engine directly (`data.insert` /
// `data.update` in service-automation's `builtin/crud-nodes.ts`, bypassing the
// metadata-protocol ingress), so the node's `fields` map arrives as an ordinary
// CALLER payload — and [#13858] the declared-field door (#8682 insert, #8738
// update) refuses a caller-named undeclared key from the object's field map
// before any statement is built. Every branch below was measured through the
// real AutomationEngine, the real builtin CRUD nodes, the real engine and BOTH
// driver families (driver-sql on better-sqlite3, driver-memory), not inferred:
//
// • Through the engine, an undeclared key reaches `driver.update` /
// `driver.create` verbatim, alongside the audit stamps.
// • On SQLite/knex an UPDATE becomes `update "deal" set "name" = 'n2',
// "stagee" = 'won' … → no such column: stagee`. The statement is rejected
// WHOLE: `name` — spelled correctly, in the same payload — does not land
// either, and the step fails with a driver error naming a column, far from
// the authoring mistake.
// • An INSERT fails the same way (`table deal has no column named stagee`),
// and one notch harder: the row is never created at all, so every later
// node that expected `{<node>.id}` is working from a record that does not
// exist.
// • On a schemaless datasource (memory, MongoDB) nothing rejects it, so the
// stray key is persisted into a column the object never declares — where no
// schema-driven read surface will return it.
// • Both families answer identically — `INVALID_FIELD` / 400, "Unknown field
// 'stagee' on object 'deal'". No driver is reached, so there is no split to
// observe.
// • The write is refused WHOLE: `name` — spelled correctly, in the same
// payload — does not land either.
// • On `create_record` the row is never created at all, so every later node
// that expected `{<node>.id}` is working from a record that does not exist.
// • The node catches the refusal and folds it into a step failure
// (`create_record(deal) failed: Unknown field 'stagee' on object 'deal'`),
// so the RUN fails — far from the authoring mistake, which is exactly why
// an author-time rule is still worth having.
//
// ⚠️ Until #13858 this block described the pre-#8682 driver split (SQL rejected
// the statement, a schemaless datasource persisted the stray key). That is
// retired, not merely restated: the severity below is unchanged because neither
// the old outcome nor the new one is ever "the rest still works".
//
// No outcome is "the rest still works". That is the same call
// `validate-searchable-fields` makes for a stale entry and
Expand DownExpand Up@@ -290,12 +294,18 @@ export function validateFlowNodeWrites(stack: AnyRec): FlowNodeWriteFinding[] {
where: `flow "${flowName}" › ${nodeWhere}`,
path: `${nodePath}.config.fields.${fieldName}`,
message:
`${node.type} writes '${fieldName}', but object '${objectName}' declares no such field. Nothing ` +
`between the node and storage removes the key: on a SQL datasource the driver rejects the whole ` +
`statement ('no such column'), so the correctly named fields in this same payload never land ` +
`either${
// [#13858] The node hands `fields` to the data engine directly
// (`data.insert` / `data.update` in service-automation's
// crud-nodes), so it is a CALLER payload and the #8682/#8738
// declared-field door refuses it before any datasource is reached.
// Measured on driver-sql and driver-memory alike.
`${node.type} writes '${fieldName}', but object '${objectName}' declares no such field. The ` +
`node hands its fields map to the engine as an ordinary caller payload, so the ` +
`declared-field door REFUSES the whole write — INVALID_FIELD / 400, identically on every ` +
`datasource, before any statement is built. The correctly named fields in this same payload ` +
`never land either${
node.type === 'create_record' ? ' and the record is never created at all' : ''
}; on a schemaless one the stray key is persisted into a column no read surface returns.`,
}, and the step fails the run.`,
hint: fixHint(fieldName, [...known]),
});
}
Expand Down
37 changes: 37 additions & 0 deletions packages/lint/src/validate-hook-body-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -246,6 +246,43 @@ describe('validateHookBodyWrites — ctx.api writes', () => {
expect(findings[0].hint).toContain("'email'");
});

// [#13858] The message is the whole product of an advisory rule, so the
// sentence IS the deliverable. It used to promise a driver-dependent outcome
// ("on a SQL driver … a driver-level error; on a schemaless driver … the
// stray key is persisted"), which has not been true for this path since
// #8682/#8738: `ctx.api` is a ScopedContext over the running engine, so the
// payload is CALLER-supplied and the declared-field door refuses it first.
//
// Measured before this text was written — real QuickJS sandbox, real hook
// body, real ObjectQL, real driver-sql (better-sqlite3) AND real
// driver-memory: both families answered `INVALID_FIELD` / 400, "Unknown field
// 'stagee' on object 'deal'", the target row was untouched, and the memory
// family stored no shadow column. Same door the caller-payload half of
// `undeclared-field-write-driver-split.integration.test.ts` pins.
it('states the measured refusal — INVALID_FIELD / 400 on every driver — and no driver split', () => {
const [finding] = validateHookBodyWrites(
stackWith("await ctx.api.object('crm_deal').update({ id, stag: 'won' });"),
);

// What the author actually gets, in the vocabulary #13657 landed for the
// `ctx.input` sibling one branch over — one door, one phrasing.
expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every driver');
expect(finding.message).toContain('before any statement is built');
// Why it is refused there rather than by a driver: the payload is a
// CALLER's, which is the fact the whole rewrite turns on.
expect(finding.message).toContain('ordinary CALLER write');
// ...and the blast radius that makes an author-time rule worth having.
expect(finding.message).toContain('fails the operation that triggered the hook');

// The retired claim, in both halves. Neither may come back without a
// measurement saying it should.
expect(finding.message).not.toMatch(/driver-level error/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/write-path validator skips/);
});

it('checks updateById payloads at argument 1, not 0', () => {
const findings = validateHookBodyWrites(
stackWith("await ctx.api.object('crm_deal').updateById(ctx.input.id, { stag: 'won' });"),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
53 changes: 53 additions & 0 deletions .changeset/write-set-messages-drop-driver-split.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/lint": patch
---

fix(lint): the three write-set rule messages now state the refusal authors actually get, not a retired driver split (#13858)

Message text only. Rule ids, severities, match sets and hints are untouched, and
no finding changes shape — but a lint's own header states why the prose is
governed: *"a lint that misdescribes the failure it is warning about teaches the
wrong debugging instinct"*. These three sentences did.

`validate-hook-body-writes` (the `ctx.api` branch), `validate-action-body-writes`
and `validate-flow-node-writes` all told the author that an undeclared write has
a **driver-dependent** outcome:

> 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

For the paths those three rules judge, that has not been true since the
declared-field door landed (#8682 insert, #8738 update). All three describe a
write whose payload is **caller-supplied**, not a mutation of an in-flight
`ctx.input`: `ctx.api` is a `ScopedContext` over the running engine, and a flow
node hands its `fields` map to the data engine directly. The door refuses a
caller-named undeclared key from the object's field map **before any statement is
built**, so no driver is reached and there is no split to observe.

Measured before the prose was rewritten — all three paths, both driver families,
through a real QuickJS sandbox, a real `ObjectQL` engine, the real
`AutomationEngine` with the real builtin CRUD node executors, real
`@objectstack/driver-sql` (better-sqlite3) and real `@objectstack/driver-memory`:

| path | driver-sql | driver-memory |
|---|---|---|
| hook body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
| action body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
| flow `create_record` / `update_record` `fields` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |

Every run answered `Unknown field 'stagee' on object 'deal'`; nothing was stored
on either family, and the schemaless family kept **no** shadow column — the half
the old message promised and the runtime no longer delivers.

The three messages now name that refusal in the vocabulary the `ctx.input`
sibling landed with (`REFUSED at run time — INVALID_FIELD / 400, identically on
every driver`), say why the door and not a driver answers, and keep each path's
own blast radius: the hook refusal fails the operation that triggered the hook,
the action refusal fails the action, and the flow node's refusal is whole — the
correctly named fields in the same payload never land either, `create_record`
never creates the row, and the step fails the run. That last clause is why the
flow rule still gates at `error`; the severity is unchanged.

`unprovisionedAnchorWriteConsequence()` in the same files is **untouched**: an
ADR-0015 external object's injected anchor *is* declared in the registered
schema, so it passes the door by construction and the remote database really is
what refuses it. That message was already correct.
29 changes: 29 additions & 0 deletions packages/lint/src/validate-action-body-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,35 @@ describe('validateActionBodyWrites — ctx.api writes', () => {
expect(findings[0].hint).toContain("'discount_total'");
});

// [#13858] The same rewrite as the hook sibling, from the same measurement:
// real QuickJS sandbox, a real L2 ACTION body run through
// `actionBodyRunnerFactory`, a real ObjectQL engine, real driver-sql
// (better-sqlite3) AND real driver-memory. Both families answered
// `INVALID_FIELD` / 400, "Unknown field 'stagee' on object 'deal'"; the
// target row was untouched and the memory family stored no shadow column.
// The old text promised a driver-level error on SQL and a persisted stray
// key on schemaless — neither happens on this path, and has not since
// #8682/#8738 put the declared-field door ahead of any statement.
it('states the measured refusal — INVALID_FIELD / 400 on every driver — and no driver split', () => {
const [finding] = validateActionBodyWrites(
stackWith("await ctx.api.object('crm_deal').update({ discont_total: 0 });"),
);

expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every driver');
expect(finding.message).toContain('before any statement is built');
// The reason the door — not a driver — is what answers.
expect(finding.message).toContain('ordinary CALLER write');
// The action-side blast radius, the one word that differs from the hook
// sibling's sentence. Pinned so a future sweep cannot flatten the two.
expect(finding.message).toContain('fails the action');

expect(finding.message).not.toMatch(/driver-level error/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/write-path validator skips/);
});

it('checks insert/create/update payloads (argument 0) and updateById at argument 1', () => {
const findings = validateActionBodyWrites(
stackWith(
Expand Down
30 changes: 20 additions & 10 deletions packages/lint/src/validate-action-body-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,18 @@
// `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 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.
// writes a field the target object never declares is refused at run time, far
// from the authoring mistake. [#13858] That refusal is NOT driver-dependent,
// and the message says so: this rule judges exactly one shape,
// `ctx.api.object('<literal>').insert|create|update|updateById(…)`, and
// `ctx.api` is a ScopedContext over the running engine, so the payload is
// CALLER-supplied and the declared-field door (#8682 insert, #8738 update)
// refuses it — `INVALID_FIELD` / 400, identically on driver-sql and
// driver-memory, before any statement is built. Measured on both families
// through the real sandbox and the real engine; the caller-payload half of
// that door is pinned in
// `undeclared-field-write-driver-split.integration.test.ts`. The hook rule
// alone left half the surface uncovered, which is why this file exists.
//
// ─── What does NOT carry over ───────────────────────────────────────────────
//
Expand DownExpand Up@@ -429,9 +434,14 @@ 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 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).`,
// [#13858] Same door, same measurement as the hook sibling — ctx.api
// is a ScopedContext over the running engine, so this payload is
// CALLER-supplied and #8682/#8738 refuse it before any driver.
`object '${w.object}' declares no such field. ctx.api is a scoped handle on the running ` +
`engine, so the payload arrives as an ordinary CALLER write and the declared-field door ` +
`REFUSES it at run time — INVALID_FIELD / 400, identically on every driver (#4271), before ` +
`any statement is built. The write lands nothing, and the refusal escapes the body and ` +
`fails the action.`,
hint: fixHint(w.field, [...known]),
});
}
Expand Down
35 changes: 35 additions & 0 deletions packages/lint/src/validate-flow-node-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -181,6 +181,41 @@ describe('validateFlowNodeWrites', () => {
expect(findings[0].hint).toMatch(/Did you mean (one of: )?'stage'/);
});

// [#13858] This rule GATES (severity `error`), so its message is what an
// author reads while their build is refused — the one place a wrong causal
// story costs the most. It used to say "on a SQL datasource the driver
// rejects the whole statement ('no such column') … on a schemaless one the
// stray key is persisted". Measured through the real AutomationEngine, the
// real builtin CRUD nodes, a real ObjectQL engine and BOTH families
// (driver-sql on better-sqlite3, driver-memory): neither happens. Both
// answered `INVALID_FIELD` / 400, "Unknown field 'stagee' on object 'deal'",
// the node folded that into `create_record(deal) failed: …`, the run failed,
// and nothing was stored on either family — no row on create, an untouched
// row and no shadow column on update.
it('states the measured refusal — INVALID_FIELD / 400 on every datasource — and no driver split', () => {
const [finding] = validateFlowNodeWrites({
objects: [dealObject],
flows: [flowWith({ stagee: 'won' })],
});

expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every datasource');
expect(finding.message).toContain('before any statement is built');
// Why the door answers and not a datasource: the node hands `fields`
// straight to the data engine, so it is a caller payload.
expect(finding.message).toContain('ordinary caller payload');
// The severity's own justification, unchanged by the rewrite and still
// stated: the refusal is WHOLE, so correctly named siblings are lost too.
expect(finding.message).toContain('never land either');
expect(finding.message).toContain('the step fails the run');

// The retired driver split, both halves.
expect(finding.message).not.toMatch(/no such column/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/Nothing between the node and storage/);
});

it('flags every unknown key in one node, and only those', () => {
const findings = validateFlowNodeWrites({
objects: [dealObject],
Expand Down
60 changes: 35 additions & 25 deletions packages/lint/src/validate-flow-node-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,27 +26,31 @@
//
// And the runtime consequence is not the benign "consumer skips the unknown
// name and does the rest" that keeps `page-field-unknown` / `form-field-unknown`
// advisory. Nothing between the node and storage removes the key: the flow
// executor calls the data engine directly (bypassing the metadata-protocol
// ingress, which strips `readonly` — not unknown — keys anyway), the engine's
// write paths strip only readonly/readonlyWhen, and the SQL driver's
// `formatInput` / `applyWriteColumnMap` pass an unrecognized key straight
// through (`m[k] ?? k`). Every branch below was measured, not inferred:
// advisory. The flow executor calls the data engine directly (`data.insert` /
// `data.update` in service-automation's `builtin/crud-nodes.ts`, bypassing the
// metadata-protocol ingress), so the node's `fields` map arrives as an ordinary
// CALLER payload — and [#13858] the declared-field door (#8682 insert, #8738
// update) refuses a caller-named undeclared key from the object's field map
// before any statement is built. Every branch below was measured through the
// real AutomationEngine, the real builtin CRUD nodes, the real engine and BOTH
// driver families (driver-sql on better-sqlite3, driver-memory), not inferred:
//
// • Through the engine, an undeclared key reaches `driver.update` /
// `driver.create` verbatim, alongside the audit stamps.
// • On SQLite/knex an UPDATE becomes `update "deal" set "name" = 'n2',
// "stagee" = 'won' … → no such column: stagee`. The statement is rejected
// WHOLE: `name` — spelled correctly, in the same payload — does not land
// either, and the step fails with a driver error naming a column, far from
// the authoring mistake.
// • An INSERT fails the same way (`table deal has no column named stagee`),
// and one notch harder: the row is never created at all, so every later
// node that expected `{<node>.id}` is working from a record that does not
// exist.
// • On a schemaless datasource (memory, MongoDB) nothing rejects it, so the
// stray key is persisted into a column the object never declares — where no
// schema-driven read surface will return it.
// • Both families answer identically — `INVALID_FIELD` / 400, "Unknown field
// 'stagee' on object 'deal'". No driver is reached, so there is no split to
// observe.
// • The write is refused WHOLE: `name` — spelled correctly, in the same
// payload — does not land either.
// • On `create_record` the row is never created at all, so every later node
// that expected `{<node>.id}` is working from a record that does not exist.
// • The node catches the refusal and folds it into a step failure
// (`create_record(deal) failed: Unknown field 'stagee' on object 'deal'`),
// so the RUN fails — far from the authoring mistake, which is exactly why
// an author-time rule is still worth having.
//
// ⚠️ Until #13858 this block described the pre-#8682 driver split (SQL rejected
// the statement, a schemaless datasource persisted the stray key). That is
// retired, not merely restated: the severity below is unchanged because neither
// the old outcome nor the new one is ever "the rest still works".
//
// No outcome is "the rest still works". That is the same call
// `validate-searchable-fields` makes for a stale entry and
Expand DownExpand Up@@ -290,12 +294,18 @@ export function validateFlowNodeWrites(stack: AnyRec): FlowNodeWriteFinding[] {
where: `flow "${flowName}" › ${nodeWhere}`,
path: `${nodePath}.config.fields.${fieldName}`,
message:
`${node.type} writes '${fieldName}', but object '${objectName}' declares no such field. Nothing ` +
`between the node and storage removes the key: on a SQL datasource the driver rejects the whole ` +
`statement ('no such column'), so the correctly named fields in this same payload never land ` +
`either${
// [#13858] The node hands `fields` to the data engine directly
// (`data.insert` / `data.update` in service-automation's
// crud-nodes), so it is a CALLER payload and the #8682/#8738
// declared-field door refuses it before any datasource is reached.
// Measured on driver-sql and driver-memory alike.
`${node.type} writes '${fieldName}', but object '${objectName}' declares no such field. The ` +
`node hands its fields map to the engine as an ordinary caller payload, so the ` +
`declared-field door REFUSES the whole write — INVALID_FIELD / 400, identically on every ` +
`datasource, before any statement is built. The correctly named fields in this same payload ` +
`never land either${
node.type === 'create_record' ? ' and the record is never created at all' : ''
}; on a schemaless one the stray key is persisted into a column no read surface returns.`,
}, and the step fails the run.`,
hint: fixHint(fieldName, [...known]),
});
}
Expand Down
37 changes: 37 additions & 0 deletions packages/lint/src/validate-hook-body-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -246,6 +246,43 @@ describe('validateHookBodyWrites — ctx.api writes', () => {
expect(findings[0].hint).toContain("'email'");
});

// [#13858] The message is the whole product of an advisory rule, so the
// sentence IS the deliverable. It used to promise a driver-dependent outcome
// ("on a SQL driver … a driver-level error; on a schemaless driver … the
// stray key is persisted"), which has not been true for this path since
// #8682/#8738: `ctx.api` is a ScopedContext over the running engine, so the
// payload is CALLER-supplied and the declared-field door refuses it first.
//
// Measured before this text was written — real QuickJS sandbox, real hook
// body, real ObjectQL, real driver-sql (better-sqlite3) AND real
// driver-memory: both families answered `INVALID_FIELD` / 400, "Unknown field
// 'stagee' on object 'deal'", the target row was untouched, and the memory
// family stored no shadow column. Same door the caller-payload half of
// `undeclared-field-write-driver-split.integration.test.ts` pins.
it('states the measured refusal — INVALID_FIELD / 400 on every driver — and no driver split', () => {
const [finding] = validateHookBodyWrites(
stackWith("await ctx.api.object('crm_deal').update({ id, stag: 'won' });"),
);

// What the author actually gets, in the vocabulary #13657 landed for the
// `ctx.input` sibling one branch over — one door, one phrasing.
expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every driver');
expect(finding.message).toContain('before any statement is built');
// Why it is refused there rather than by a driver: the payload is a
// CALLER's, which is the fact the whole rewrite turns on.
expect(finding.message).toContain('ordinary CALLER write');
// ...and the blast radius that makes an author-time rule worth having.
expect(finding.message).toContain('fails the operation that triggered the hook');

// The retired claim, in both halves. Neither may come back without a
// measurement saying it should.
expect(finding.message).not.toMatch(/driver-level error/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/write-path validator skips/);
});

it('checks updateById payloads at argument 1, not 0', () => {
const findings = validateHookBodyWrites(
stackWith("await ctx.api.object('crm_deal').updateById(ctx.input.id, { stag: 'won' });"),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
53 changes: 53 additions & 0 deletions .changeset/write-set-messages-drop-driver-split.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/lint": patch
---

fix(lint): the three write-set rule messages now state the refusal authors actually get, not a retired driver split (#13858)

Message text only. Rule ids, severities, match sets and hints are untouched, and
no finding changes shape — but a lint's own header states why the prose is
governed: *"a lint that misdescribes the failure it is warning about teaches the
wrong debugging instinct"*. These three sentences did.

`validate-hook-body-writes` (the `ctx.api` branch), `validate-action-body-writes`
and `validate-flow-node-writes` all told the author that an undeclared write has
a **driver-dependent** outcome:

> 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

For the paths those three rules judge, that has not been true since the
declared-field door landed (#8682 insert, #8738 update). All three describe a
write whose payload is **caller-supplied**, not a mutation of an in-flight
`ctx.input`: `ctx.api` is a `ScopedContext` over the running engine, and a flow
node hands its `fields` map to the data engine directly. The door refuses a
caller-named undeclared key from the object's field map **before any statement is
built**, so no driver is reached and there is no split to observe.

Measured before the prose was rewritten — all three paths, both driver families,
through a real QuickJS sandbox, a real `ObjectQL` engine, the real
`AutomationEngine` with the real builtin CRUD node executors, real
`@objectstack/driver-sql` (better-sqlite3) and real `@objectstack/driver-memory`:

| path | driver-sql | driver-memory |
|---|---|---|
| hook body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
| action body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
| flow `create_record` / `update_record` `fields` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |

Every run answered `Unknown field 'stagee' on object 'deal'`; nothing was stored
on either family, and the schemaless family kept **no** shadow column — the half
the old message promised and the runtime no longer delivers.

The three messages now name that refusal in the vocabulary the `ctx.input`
sibling landed with (`REFUSED at run time — INVALID_FIELD / 400, identically on
every driver`), say why the door and not a driver answers, and keep each path's
own blast radius: the hook refusal fails the operation that triggered the hook,
the action refusal fails the action, and the flow node's refusal is whole — the
correctly named fields in the same payload never land either, `create_record`
never creates the row, and the step fails the run. That last clause is why the
flow rule still gates at `error`; the severity is unchanged.

`unprovisionedAnchorWriteConsequence()` in the same files is **untouched**: an
ADR-0015 external object's injected anchor *is* declared in the registered
schema, so it passes the door by construction and the remote database really is
what refuses it. That message was already correct.
29 changes: 29 additions & 0 deletions packages/lint/src/validate-action-body-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,6 +167,35 @@ describe('validateActionBodyWrites — ctx.api writes', () => {
expect(findings[0].hint).toContain("'discount_total'");
});

// [#13858] The same rewrite as the hook sibling, from the same measurement:
// real QuickJS sandbox, a real L2 ACTION body run through
// `actionBodyRunnerFactory`, a real ObjectQL engine, real driver-sql
// (better-sqlite3) AND real driver-memory. Both families answered
// `INVALID_FIELD` / 400, "Unknown field 'stagee' on object 'deal'"; the
// target row was untouched and the memory family stored no shadow column.
// The old text promised a driver-level error on SQL and a persisted stray
// key on schemaless — neither happens on this path, and has not since
// #8682/#8738 put the declared-field door ahead of any statement.
it('states the measured refusal — INVALID_FIELD / 400 on every driver — and no driver split', () => {
const [finding] = validateActionBodyWrites(
stackWith("await ctx.api.object('crm_deal').update({ discont_total: 0 });"),
);

expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every driver');
expect(finding.message).toContain('before any statement is built');
// The reason the door — not a driver — is what answers.
expect(finding.message).toContain('ordinary CALLER write');
// The action-side blast radius, the one word that differs from the hook
// sibling's sentence. Pinned so a future sweep cannot flatten the two.
expect(finding.message).toContain('fails the action');

expect(finding.message).not.toMatch(/driver-level error/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/write-path validator skips/);
});

it('checks insert/create/update payloads (argument 0) and updateById at argument 1', () => {
const findings = validateActionBodyWrites(
stackWith(
Expand Down
30 changes: 20 additions & 10 deletions packages/lint/src/validate-action-body-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,18 @@
// `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 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.
// writes a field the target object never declares is refused at run time, far
// from the authoring mistake. [#13858] That refusal is NOT driver-dependent,
// and the message says so: this rule judges exactly one shape,
// `ctx.api.object('<literal>').insert|create|update|updateById(…)`, and
// `ctx.api` is a ScopedContext over the running engine, so the payload is
// CALLER-supplied and the declared-field door (#8682 insert, #8738 update)
// refuses it — `INVALID_FIELD` / 400, identically on driver-sql and
// driver-memory, before any statement is built. Measured on both families
// through the real sandbox and the real engine; the caller-payload half of
// that door is pinned in
// `undeclared-field-write-driver-split.integration.test.ts`. The hook rule
// alone left half the surface uncovered, which is why this file exists.
//
// ─── What does NOT carry over ───────────────────────────────────────────────
//
Expand DownExpand Up@@ -429,9 +434,14 @@ 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 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).`,
// [#13858] Same door, same measurement as the hook sibling — ctx.api
// is a ScopedContext over the running engine, so this payload is
// CALLER-supplied and #8682/#8738 refuse it before any driver.
`object '${w.object}' declares no such field. ctx.api is a scoped handle on the running ` +
`engine, so the payload arrives as an ordinary CALLER write and the declared-field door ` +
`REFUSES it at run time — INVALID_FIELD / 400, identically on every driver (#4271), before ` +
`any statement is built. The write lands nothing, and the refusal escapes the body and ` +
`fails the action.`,
hint: fixHint(w.field, [...known]),
});
}
Expand Down
35 changes: 35 additions & 0 deletions packages/lint/src/validate-flow-node-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -181,6 +181,41 @@ describe('validateFlowNodeWrites', () => {
expect(findings[0].hint).toMatch(/Did you mean (one of: )?'stage'/);
});

// [#13858] This rule GATES (severity `error`), so its message is what an
// author reads while their build is refused — the one place a wrong causal
// story costs the most. It used to say "on a SQL datasource the driver
// rejects the whole statement ('no such column') … on a schemaless one the
// stray key is persisted". Measured through the real AutomationEngine, the
// real builtin CRUD nodes, a real ObjectQL engine and BOTH families
// (driver-sql on better-sqlite3, driver-memory): neither happens. Both
// answered `INVALID_FIELD` / 400, "Unknown field 'stagee' on object 'deal'",
// the node folded that into `create_record(deal) failed: …`, the run failed,
// and nothing was stored on either family — no row on create, an untouched
// row and no shadow column on update.
it('states the measured refusal — INVALID_FIELD / 400 on every datasource — and no driver split', () => {
const [finding] = validateFlowNodeWrites({
objects: [dealObject],
flows: [flowWith({ stagee: 'won' })],
});

expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every datasource');
expect(finding.message).toContain('before any statement is built');
// Why the door answers and not a datasource: the node hands `fields`
// straight to the data engine, so it is a caller payload.
expect(finding.message).toContain('ordinary caller payload');
// The severity's own justification, unchanged by the rewrite and still
// stated: the refusal is WHOLE, so correctly named siblings are lost too.
expect(finding.message).toContain('never land either');
expect(finding.message).toContain('the step fails the run');

// The retired driver split, both halves.
expect(finding.message).not.toMatch(/no such column/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/Nothing between the node and storage/);
});

it('flags every unknown key in one node, and only those', () => {
const findings = validateFlowNodeWrites({
objects: [dealObject],
Expand Down
60 changes: 35 additions & 25 deletions packages/lint/src/validate-flow-node-writes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,27 +26,31 @@
//
// And the runtime consequence is not the benign "consumer skips the unknown
// name and does the rest" that keeps `page-field-unknown` / `form-field-unknown`
// advisory. Nothing between the node and storage removes the key: the flow
// executor calls the data engine directly (bypassing the metadata-protocol
// ingress, which strips `readonly` — not unknown — keys anyway), the engine's
// write paths strip only readonly/readonlyWhen, and the SQL driver's
// `formatInput` / `applyWriteColumnMap` pass an unrecognized key straight
// through (`m[k] ?? k`). Every branch below was measured, not inferred:
// advisory. The flow executor calls the data engine directly (`data.insert` /
// `data.update` in service-automation's `builtin/crud-nodes.ts`, bypassing the
// metadata-protocol ingress), so the node's `fields` map arrives as an ordinary
// CALLER payload — and [#13858] the declared-field door (#8682 insert, #8738
// update) refuses a caller-named undeclared key from the object's field map
// before any statement is built. Every branch below was measured through the
// real AutomationEngine, the real builtin CRUD nodes, the real engine and BOTH
// driver families (driver-sql on better-sqlite3, driver-memory), not inferred:
//
// • Through the engine, an undeclared key reaches `driver.update` /
// `driver.create` verbatim, alongside the audit stamps.
// • On SQLite/knex an UPDATE becomes `update "deal" set "name" = 'n2',
// "stagee" = 'won' … → no such column: stagee`. The statement is rejected
// WHOLE: `name` — spelled correctly, in the same payload — does not land
// either, and the step fails with a driver error naming a column, far from
// the authoring mistake.
// • An INSERT fails the same way (`table deal has no column named stagee`),
// and one notch harder: the row is never created at all, so every later
// node that expected `{<node>.id}` is working from a record that does not
// exist.
// • On a schemaless datasource (memory, MongoDB) nothing rejects it, so the
// stray key is persisted into a column the object never declares — where no
// schema-driven read surface will return it.
// • Both families answer identically — `INVALID_FIELD` / 400, "Unknown field
// 'stagee' on object 'deal'". No driver is reached, so there is no split to
// observe.
// • The write is refused WHOLE: `name` — spelled correctly, in the same
// payload — does not land either.
// • On `create_record` the row is never created at all, so every later node
// that expected `{<node>.id}` is working from a record that does not exist.
// • The node catches the refusal and folds it into a step failure
// (`create_record(deal) failed: Unknown field 'stagee' on object 'deal'`),
// so the RUN fails — far from the authoring mistake, which is exactly why
// an author-time rule is still worth having.
//
// ⚠️ Until #13858 this block described the pre-#8682 driver split (SQL rejected
// the statement, a schemaless datasource persisted the stray key). That is
// retired, not merely restated: the severity below is unchanged because neither
// the old outcome nor the new one is ever "the rest still works".
//
// No outcome is "the rest still works". That is the same call
// `validate-searchable-fields` makes for a stale entry and
Expand DownExpand Up@@ -290,12 +294,18 @@ export function validateFlowNodeWrites(stack: AnyRec): FlowNodeWriteFinding[] {
where: `flow "${flowName}" › ${nodeWhere}`,
path: `${nodePath}.config.fields.${fieldName}`,
message:
`${node.type} writes '${fieldName}', but object '${objectName}' declares no such field. Nothing ` +
`between the node and storage removes the key: on a SQL datasource the driver rejects the whole ` +
`statement ('no such column'), so the correctly named fields in this same payload never land ` +
`either${
// [#13858] The node hands `fields` to the data engine directly
// (`data.insert` / `data.update` in service-automation's
// crud-nodes), so it is a CALLER payload and the #8682/#8738
// declared-field door refuses it before any datasource is reached.
// Measured on driver-sql and driver-memory alike.
`${node.type} writes '${fieldName}', but object '${objectName}' declares no such field. The ` +
`node hands its fields map to the engine as an ordinary caller payload, so the ` +
`declared-field door REFUSES the whole write — INVALID_FIELD / 400, identically on every ` +
`datasource, before any statement is built. The correctly named fields in this same payload ` +
`never land either${
node.type === 'create_record' ? ' and the record is never created at all' : ''
}; on a schemaless one the stray key is persisted into a column no read surface returns.`,
}, and the step fails the run.`,
hint: fixHint(fieldName, [...known]),
});
}
Expand Down
37 changes: 37 additions & 0 deletions packages/lint/src/validate-hook-body-writes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -246,6 +246,43 @@ describe('validateHookBodyWrites — ctx.api writes', () => {
expect(findings[0].hint).toContain("'email'");
});

// [#13858] The message is the whole product of an advisory rule, so the
// sentence IS the deliverable. It used to promise a driver-dependent outcome
// ("on a SQL driver … a driver-level error; on a schemaless driver … the
// stray key is persisted"), which has not been true for this path since
// #8682/#8738: `ctx.api` is a ScopedContext over the running engine, so the
// payload is CALLER-supplied and the declared-field door refuses it first.
//
// Measured before this text was written — real QuickJS sandbox, real hook
// body, real ObjectQL, real driver-sql (better-sqlite3) AND real
// driver-memory: both families answered `INVALID_FIELD` / 400, "Unknown field
// 'stagee' on object 'deal'", the target row was untouched, and the memory
// family stored no shadow column. Same door the caller-payload half of
// `undeclared-field-write-driver-split.integration.test.ts` pins.
it('states the measured refusal — INVALID_FIELD / 400 on every driver — and no driver split', () => {
const [finding] = validateHookBodyWrites(
stackWith("await ctx.api.object('crm_deal').update({ id, stag: 'won' });"),
);

// What the author actually gets, in the vocabulary #13657 landed for the
// `ctx.input` sibling one branch over — one door, one phrasing.
expect(finding.message).toContain('INVALID_FIELD / 400');
expect(finding.message).toContain('identically on every driver');
expect(finding.message).toContain('before any statement is built');
// Why it is refused there rather than by a driver: the payload is a
// CALLER's, which is the fact the whole rewrite turns on.
expect(finding.message).toContain('ordinary CALLER write');
// ...and the blast radius that makes an author-time rule worth having.
expect(finding.message).toContain('fails the operation that triggered the hook');

// The retired claim, in both halves. Neither may come back without a
// measurement saying it should.
expect(finding.message).not.toMatch(/driver-level error/);
expect(finding.message).not.toMatch(/schemaless/);
expect(finding.message).not.toMatch(/is persisted/);
expect(finding.message).not.toMatch(/write-path validator skips/);
});

it('checks updateById payloads at argument 1, not 0', () => {
const findings = validateHookBodyWrites(
stackWith("await ctx.api.object('crm_deal').updateById(ctx.input.id, { stag: 'won' });"),
Expand Down
Loading
Loading