Skip to content

test(runtime,cli): pin the ctx.api and flow-node undeclared-field-write refusals - #15370

Merged
os-litant merged 6 commits into
mainfrom
claude/issue-14241-ctx-api-flow-node-undeclared-write-pins
Sep 4, 2026
Merged

test(runtime,cli): pin the ctx.api and flow-node undeclared-field-write refusals#15370
os-litant merged 6 commits into
mainfrom
claude/issue-14241-ctx-api-flow-node-undeclared-write-pins

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #14241

Pins only — no packages/runtime or packages/cli source change, no lint-rule edit.

The three lint messages #13858 rewrote (hook-body-write-unknown-field,
action-body-write-unknown-field, and the gating flow-node-write-unknown-field,
severity: 'error') now assert a runtime behaviour that nothing pinned: the harness that
measured it was a scratch and was deleted. The existing driver-split pin corroborates a
different call shape, so if the door moved for the ctx.api or flow-node paths
specifically, every gate would stay green while three author-facing sentences quietly went
wrong. This adds the two missing shapes.

What landed

packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts — the
ctx.api half, in the existing ruled file's own idiom: two it.each(FAMILIES) blocks plus a
control, run on both driver families. A hook body and an action body each issue
ctx.api.object('deal').update({ stagee: 'won' }) against a seeded row. Each case asserts the
ADR-0112 envelope on the error that escapes — code: 'INVALID_FIELD' and status: 400,
never a bare toThrow() — that it arrives as a SandboxError carrying the payload across the
VM boundary (the hop #3918 and #7867 each broke once), the refusal message verbatim, and that
nothing landed: the target row untouched, no shadow column, and the write that triggered the
body refused with it.

packages/cli/src/flow-node-undeclared-field-write.integration.test.ts (new) — the flow-node
half, through the real AutomationEngine, the real builtin CRUD node executors, a real
ObjectQL and a real driver-sql on better-sqlite3. create_record and update_record each
fail the RUN; the step's error is pinned whole, { code: 'NODE_FAILURE', message: "create_record(deal) failed: Unknown field 'stagee' on object 'deal'" } — so the reader can
see that the door's INVALID_FIELD is not what a run reports, only its message survives the
fold. No row is created; the row under update keeps its values and grows no shadow column; and
the correctly named field riding in the same fields map does not land either. The door's own
INVALID_FIELD / 400 envelope is asserted at the seam the node hands its payload to, which is
the only place in the flow chain where the code and the status are still observable.

The measurement was re-driven, not inherited

All six shapes were driven again in this PR rather than taken from #13858's report, and all six
agree with it: INVALID_FIELD / 400, Unknown field 'stagee' on object 'deal', nothing
stored, no shadow column on the schemaless family. Nothing here contradicts the card.

Two things the card asked to be confirmed, and one deviation

The location claim holds. packages/cli is the only package declaring
@objectstack/service-automation and both driver families — checked across the eleven
packages that declare service-automation at all.

The flow half runs on one driver family, deliberately. The schemaless witness in this repo
is @objectstack/driver-memory, whose every declaration is disposed of in
scripts/driver-memory-census.ledger.json and gated by pnpm check:driver-memory-census
(#6664, from #5704 / #5499). A new test consumer there is an unledgered arrival: admitting one
is a maintainer ruling and would additionally force rewriting the census sentence in both
existing ruled files, so it is not a test author's call. The CLI's own ledger entry records
that the CLI imports the driver nowhere, and it still does.

What replaces the second arm is stronger than a second sample, and is why this is not a
sacrifice: the lint prose's "identically on every datasource" is true because no driver is
reached
, so the flow file counts every write verb the real driver is asked to perform and
asserts zero on each refusal case. A family split cannot arise below a door nothing gets
past. The schemaless persistence half, for the caller-payload shape a flow node's fields map
literally is, stays pinned by the ruled runtime file's existing caller-payload block — on both
families, including the no-shadow-column assertion. Both files say all of this in place.

The same constraint is why the ctx.api half extends the existing ruled file instead of
landing in a sibling: a sibling would need its own driver-memory import, i.e. the same
unledgered arrival. check:driver-memory-census is green.

Verification

Ablation, to show the pins can fail rather than only that they pass. With the implementation
committed first, stagee was declared on the fixture in both files — the one input the door's
verdict reads — the mutation was confirmed on disk by grep of the injected text plus a
git hash-object comparison against the HEAD blobs, and both files were re-run. No rebuild was
needed or done: the mutated text is each test file's own fixture, and the subject under test
(undeclaredWriteFieldErrors in @objectstack/objectql) is unchanged in dist, which was
built beforehand.

before under ablation
runtime pin file 22 passed 16 failed / 6 passed
cli pin file 7 passed 5 failed / 2 passed

Every refusal case went red on both families; only the controls stayed green, which is correct
— they do not depend on stagee being undeclared. The restore leg was proved, not assumed:
git diff HEAD empty, both blob hashes equal to their HEAD blobs, and the injected marker gone
(grep count 0). The mutation script carried a trap ... EXIT INT TERM with absolute paths.

Gate union derived at the final HEAD with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on a clean
tree — never a hand-written list — and re-derived after each merge with main; the union was
identical across all three derivations. 37 / 37 exit 0 at 1c8e95e5, each exit code
captured before any pipe. Plus, off the derived union but directly relevant to this change:
pnpm check:driver-memory-census green.

One derived gate was genuinely moved by this change and was fixed rather than baselined:
check:query-options-erasure counted the five new as any options bags in test code
(236 to 241 sites). They are ordinary where bags with no reason to be erased, so both files
now read them through typed EngineQueryOptions helpers and the ratchet holds at its ceiling,
236, with no baseline edit.

Also at 1c8e95e5: pnpm --filter @objectstack/runtime --filter @objectstack/cli run typecheck
green, including both check:test-typecheck legs — which matters here, because
packages/runtime/tsconfig.json excludes **/*.test.ts, so tsc --noEmit alone would have
said nothing about the edited file; tsconfig.test.json is the program that reads it.

skip-changeset: this PR is test files only and publishes nothing from any package.


Generated by Claude Code

@github-actions github-actions Bot added the size/l label Sep 4, 2026
@os-litant os-litant added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 4, 2026 — with Claude
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 13c48c2a55b403c92cee6c1788511b9562a20199packageMentionDocs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

runtime: no pin covers the ctx.api and flow-node undeclared-write paths, so the three lint messages #13858 just corrected can go stale green again

2 participants