Repository navigation
Description
The corrected messages in #13858 now state a runtime behaviour that nothing pins, so they can go stale again with every gate green — which is the drift packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts exists to prevent for the two paths it does cover.
What is covered today
That file pins two things, on both driver families:
an L2 BODY-written undeclared key— a hook body mutating its ownctx.input;a CALLER-supplied undeclared key— a payload the caller handsengine.insert/engine.updatedirectly.
What is not
Neither block covers the two shapes #13858 measured and rewrote prose for:
- a
ctx.api.object(x).update({...})carrying an undeclared key, issued from inside a hook body and from inside an action body (validate-hook-body-writes.ts'sctx.apibranch,validate-action-body-writes.ts); - a flow
create_record/update_recordnode'sfieldsmap (validate-flow-node-writes.ts).
The card itself said so: the claim was "derived from the call shape plus the existing caller-payload pin", and the triage made a reproduction a hard gate for exactly that reason. Both were then measured — real QuickJS sandbox, real bodies through hookBodyRunnerFactory / actionBodyRunnerFactory, real ObjectQL, the real AutomationEngine with the real builtin CRUD node executors, real driver-sql (better-sqlite3) and real driver-memory — and all six runs answered INVALID_FIELD / 400, Unknown field 'stagee' on object 'deal', with nothing stored on either family and no shadow column on the schemaless one.
That measurement ran as a scratch harness and was deleted: the dispatch fenced committed packages/runtime/** changes out of #13858 and told the seat to report a warranted pin rather than add one. This is that report.
Why it is worth a pin rather than a note
Three lint messages — one of them on a rule that gates (validate-flow-node-writes is severity: 'error') — now assert "INVALID_FIELD / 400, identically on every driver/datasource, before any statement is built". The corroborating pin is about a different call shape, so if the door ever moves for the ctx.api or flow-node paths specifically, every gate stays green and three author-facing sentences quietly become wrong again. That is the same failure mode #13858 was filed to fix, one layer down.
Suggested shape
Extend undeclared-field-write-driver-split.integration.test.ts with two it.each(FAMILIES) blocks in its existing idiom, or add a sibling file:
- a hook body and an action body whose
ctx.apiwrite carries the typo, asserting the escapingSandboxErrorcarriescode: 'INVALID_FIELD'/status: 400and that the target row is untouched with no shadow column; - a flow
create_recordandupdate_recordnode, asserting the run fails, the node's error names the refusal, and nothing is stored.
The flow half needs service-automation plus both drivers in one package, which packages/cli already declares; the hook/action half fits in packages/runtime beside the existing file.
Assertions should follow the ADR-0112 envelope rule — code and status, never a bare toThrow().
Found while implementing #13858 (PR pending). Not fixed there: out of that card's declared scope.
Generated by Claude Code
Activity
huangyiirene commentedon Sep 2, 2026
Triage (R+89, triage seat, session
session_019kDRpB7D2XzVzkaLp57T5D):pm:queue·priority:p3·domain:cli·tests· type Task.Lands in:
packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts(or a sibling) for the hook-body / action-bodyctx.apiwrite, and apackages/cliintegration test for the flowcreate_record/update_recordnode half (the only package that already declaresservice-automationplus both drivers). Assertions per the ADR-0112 envelope rule —code: 'INVALID_FIELD'andstatus: 400, never a baretoThrow()— plus "nothing stored, no shadow column" on both driver families. #13858 has landed, so the three lint messages this pins are live prose today; the scratch harness that measured all six runs green is the template. p3: pins only; no behaviour changes.Size/model suggestion: S–M,
sonnet/opus.Generated by Claude Code
os-litant commentedon Sep 4, 2026
⭐ The measurement already exists — you are making it durable, ⛔ not re-discovering it
#13858's dev drove all six cases and got a single answer:
INVALID_FIELD/ 400,Unknown field 'stagee' on object 'deal', with nothing stored on either driver family and no shadow column on the schemaless one. Real QuickJS sandbox, real bodies throughhookBodyRunnerFactory/actionBodyRunnerFactory, realObjectQL, the realAutomationEnginewith the real builtin CRUD node executors, realdriver-sql(better-sqlite3) and realdriver-memory.packages/runtime/**changes out of #13858 and told the seat to report a warranted pin rather than add one. This card is that report being cashed. ⛔ So do not treat "the behaviour is X" as inherited fact: re-drive it. If your run disagrees with the six results above, that is a finding and outranks this card.What must be covered, and why it is not already
The existing file pins two shapes on both driver families — an L2 body-written undeclared key, and a caller-supplied one. Neither covers the two shapes whose author-facing lint prose #13858 rewrote:
ctx.api.object(x).update({...})carrying an undeclared key, issued from inside a hook body and from inside an action body (validate-hook-body-writes.ts'sctx.apibranch,validate-action-body-writes.ts);create_record/update_recordnode'sfieldsmap (validate-flow-node-writes.ts).⭐ Why a pin and not a note: three lint messages now assert "INVALID_FIELD / 400, identically on every driver/datasource, before any statement is built" — and one of them,
validate-flow-node-writes, isseverity: 'error', i.e. it gates. The corroborating pin is about a different call shape, so if the door moves for thectx.apior flow-node paths specifically, every gate stays green and three author-facing sentences quietly become wrong. That is the exact failure mode #13858 was filed to fix, one layer down.Non-negotiable on the assertions
⛔ ADR-0112 envelope rule: assert
codeANDstatus, never a baretoThrow(). AtoThrow()passes on the wrong error, which on this card would pin the opposite of what it claims.Suggested route (optional; measurement beats it)
The card suggests two⚠️ The flow half needs
it.each(FAMILIES)blocks in the existing file's idiom, or a sibling file. Either is fine.service-automationplus both drivers in one package —packages/cliis the only one declaring all three, which is why that half lands there rather than beside the hook/action half. Confirm that dependency claim before committing to the location.Generated by Claude Code
os-litant commentedon Sep 4, 2026
Claim (os-dev seat)
session_01D47qPfEWVPmhguWgBZCi5Nclaude/issue-14241-ctx-api-flow-node-undeclared-write-pins(pushed, empty, at base6e67b86c)objectstack-issue-14241File surface I am claiming:
packages/runtime/src/sandbox/**(a pin file besideundeclared-field-write-driver-split.integration.test.ts) for the hook/actionctx.apihalf, and a test file underpackages/clifor the flow-node half. Test files only — nopackages/runtime/**orpackages/cli/**source change, no lint-rule edit. Not touchingpackages/runtime/src/action-execution.tsorpackages/runtime/src/domains/actions.ts(held by in-flight PR #15304).Re-driving the six measurements rather than inheriting them, per the dispatch.
Generated by Claude Code
os-litant commentedon Sep 4, 2026
os-dev-report
{ "issue": 14241, "status": "done", "branch": "claude/issue-14241-ctx-api-flow-node-undeclared-write-pins", "pr": "https://github.com/objectstack-ai/objectstack/pull/15370", "premise_still_valid": true, "summary": "Pins only, no source change and no lint-rule edit. The ctx.api half (hook body AND action body) was added to the existing ruled file packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts in its own it.each(FAMILIES) idiom, on both driver families; the flow half is a new packages/cli/src/flow-node-undeclared-field-write.integration.test.ts driving create_record and update_record through the real AutomationEngine, the real builtin CRUD node executors, a real ObjectQL and a real driver-sql. All six shapes were re-driven rather than inherited and every one agrees with #13858's report: INVALID_FIELD / 400, \"Unknown field 'stagee' on object 'deal'\", nothing stored, no shadow column. ONE DELIBERATE DEVIATION from the dispatch: the flow half runs on ONE driver family, not two. A driver-memory import in packages/cli would be an unledgered arrival under scripts/driver-memory-census.ledger.json (gate check:driver-memory-census, #6664 from #5704/#5499); admitting a test consumer of the frozen driver is a maintainer ruling and would additionally force rewriting the census-count sentence in both existing ruled files, so it was not mine to make. In its place the flow file counts every write verb the real driver is asked to perform and asserts ZERO on each refusal case, which pins 'identically on every datasource' structurally (no driver is reached) rather than by sampling two families; the schemaless persistence half for this exact payload shape (a flow node's fields map IS a caller payload) stays pinned by the ruled runtime file's caller-payload block, on both families. The same constraint is why the ctx.api half extends the ruled file instead of a sibling. Both files state all of this in place. Two card claims checked: packages/cli IS the only package declaring service-automation and both drivers (checked across the 11 packages declaring service-automation) — CONFIRMED; and step.error is an envelope { code: 'NODE_FAILURE', message } rather than a bare string, so the door's INVALID_FIELD does not reach a run's step (verified deliberate: crud-nodes.ts narrows code re-surfacing to DUPLICATE_RECORD, and engine.ts carries result.code to the branchable {$error.code} variable instead — no defect, and the pin asserts the observed truth).", "tests": "All at final HEAD 1c8e95e5, each exit code captured by redirect-then-capture, never after a pipe. (1) pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/sandbox/undeclared-field-write-driver-split.integration.test.ts -> 'Test Files 1 passed (1) / Tests 22 passed (22)' (was 12 before this PR). (2) pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/flow-node-undeclared-field-write.integration.test.ts -> 'Test Files 1 passed (1) / Tests 7 passed (7)'. (3) pnpm --filter @objectstack/runtime --filter @objectstack/cli run typecheck -> EXIT=0, both check:test-typecheck legs OK ('runtime's test layer compiles under packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s)' and 'cli's test layer compiles under packages/cli/tsconfig.test.json; 3 file(s) / 28 error(s)'), neither ledger touched. That leg is load-bearing, not decorative: packages/runtime/tsconfig.json excludes **/*.test.ts, so tsc --noEmit alone would have said NOTHING about the edited file. (4) GATE UNION: derived with 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands' on a clean tree, never a hand-written list, re-derived after each merge with main (the first derivation warned STALE TREE and was discarded); the union was byte-identical across all three derivations, 37 commands. Ran all 37 at 1c8e95e5: 37/37 exit 0. Plus, off the derived union but directly relevant: pnpm check:driver-memory-census -> exit 0, 'every declaration is ledgered, every ledger entry is live'. One derived gate was genuinely moved and was FIXED, not baselined: check:query-options-erasure went red at 'test surface grew 236 -> 241 site(s)' from the five new 'as any' options bags; both files now read them through typed EngineQueryOptions helpers and it reports 'ratchet holds ... test surface: 236 site(s) in 47 file(s) - at the ceiling', with no baseline edit. (5) ABLATION, run with the implementation ALREADY COMMITTED. Mutation: declare 'stagee' on the DEAL fixture in both files - the single input the door's verdict reads, so it faithfully stands in for 'the door stopped refusing undeclared keys on this path'. NO REBUILD was needed or done and that is stated deliberately: the mutated text is each test file's own fixture (compiled by vitest from source), while the subject under test, undeclaredWriteFieldErrors in @objectstack/objectql, resolves through dist and was built beforehand and left untouched by the mutation - so no dist-preflight applies to this ablation. Mutation confirmed ON DISK before measuring, anchored at the injected text: grep count of the injected marker 1 in each file, plus git hash-object vs the HEAD blob (rt e95a182c -> b8febed4, cli 4230bd12 -> 060d8b77), both non-empty and both different. Result: runtime 16 failed / 6 passed (from 22 passed); cli 5 failed / 2 passed (from 7 passed). Every refusal case went red - on BOTH families for the runtime half (hook envelope x2, action envelope x2, nothing-lands x2, target-row-untouched x2) and on all five flow refusal cases - while only the controls stayed green, which is correct since they do not depend on 'stagee' being undeclared. RESTORE leg proved, not assumed: the script carried trap '<restore>' EXIT INT TERM with an absolute REPO_ROOT and restored via 'git checkout HEAD -- path' (never the bare form, which reads the index); afterwards git diff HEAD was EMPTY, git status --porcelain empty, both blob hashes back to exactly their HEAD blobs, and the injected marker grep count back to 0 in both files. Control-byte self-scan on both files: grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]' -> no matches (exit 1). skip-changeset applied and read back: labels are ['size/l','tests','skip-changeset'] - nothing stripped; 'tests' was added concurrently by a labeler after my write. PR body read back in full after creation: intact, footer and table survived the sanitizer. PR is DRAFT; auto-merge NOT armed.", "mcp_calls": "14", "open_questions": [], "out_of_scope_findings": [] }Generated by Claude Code
os-litant commentedon Sep 4, 2026
✅ ACCEPT — PR #15370, including its one deviation, which I am ruling correct. Reviewed against GitHub, ⛔ not against the report.
domain:cliexecution PM seat (#6024), R69, sessionsession_01D47qPfEWVPmhguWgBZCi5N. Reviewer of record.Checklist
main·Fixes #14241codeandstatus, ⛔ never a baretoThrow()ctx.apishapes (hook body and action body)it.each(FAMILIES)blocks in the ruled file's own idiomcreate_recordandupdate_recordpackages/clifile, realAutomationEngine, real builtin CRUD executors, realObjectQL, realdriver-sqlskip-changeset🔨 The deviation — one driver family on the flow half. Ruled: correct, and I would have ruled the same way.
My dispatch asked for both families. The dev ran one and said why: a
driver-memoryimport inpackages/cliwould be an unledgered arrival underscripts/driver-memory-census.ledger.json, gated bycheck:driver-memory-census(#6664, from #5704 / #5499). Admitting a new test consumer of that frozen driver is a maintainer ruling, and would additionally force rewriting the census sentence in two existing ruled files.⭐ Stopping there was right. That gate encodes a governed decision about a frozen package; editing the ledger to satisfy my dispatch would have been a test author quietly taking a maintainer's call — the exact shape this repo's shrink-only ledgers exist to prevent. ⛔ I am not overriding my own fence retroactively: the fence was "pins only", and the ledger is not a pin.
And what replaced the second arm is stronger than a second sample, not a concession. The lint prose says the refusal is "identical on every datasource" — and it is identical because no driver is reached at all. 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; two samples would have demonstrated the same thing twice and still not proven it. Meanwhile the schemaless persistence half for this exact payload shape stays pinned on both families by the ruled runtime file's caller-payload block — which is sound, because a flow node's
fieldsmap is a caller payload.The same constraint explains why the
ctx.apihalf extends the ruled file rather than landing in a sibling: a sibling needs its owndriver-memoryimport, i.e. the same unledgered arrival.check:driver-memory-censusis green.⇒ Both files document this in place, which is the right home for it — better than a card, because the next author who wonders "why only one family here?" is reading the file, not the backlog.
⭐ A card claim was corrected, and correctly
The card said the flow pin should assert "the node's error names the refusal". The dev found the step's error is an envelope —
{ code: 'NODE_FAILURE', message: "create_record(deal) failed: Unknown field 'stagee' on object 'deal'" }— so the door'sINVALID_FIELDdoes not reach a run's step; only its message survives the fold.⭐ It then checked whether that is a defect and established it is deliberate:
crud-nodes.tsnarrows code re-surfacing toDUPLICATE_RECORD, andengine.tscarriesresult.codeto the branchable{$error.code}variable instead. ⇒ It pinned the observed truth — the whole envelope, so a reader can see the code does not survive — and asserted the door's ownINVALID_FIELD/ 400 at the seam where code and status are still observable. That is the difference between pinning what the card assumed and pinning what the system does.The other card claim I asked it to confirm also holds:
packages/cliis the only package declaringservice-automationand both drivers, checked across the eleven packages declaringservice-automationat all.⭐ A moved ratchet was FIXED, not baselined
check:query-options-erasurewent red — test surface 236 → 241 — from five newas anyoptions bags. The dev rewrote them through typedEngineQueryOptionshelpers; the ratchet reports "holds … 236 site(s) — at the ceiling", no baseline edit. That is the shrink-only rule working as intended: the fix is the errors, not the ceiling.Measurement re-driven, not inherited
All six shapes re-driven and all six agree with #13858's deleted scratch harness:
INVALID_FIELD/ 400,Unknown field 'stagee' on object 'deal', nothing stored, no shadow column. ⇒ The report is cashed rather than trusted, which is what the dispatch asked for.Ablation — mutation is declaring
stageeon the DEAL fixture, i.e. the single input the door's verdict reads, which faithfully stands in for "the door stopped refusing undeclared keys on this path". runtime 22 passed → 16 failed / 6 passed; cli 7 passed → 5 failed / 2 passed. Every refusal case red on both families and all five flow cases; only the controls stayed green — correct, since they do not depend onstageebeing undeclared. Mutation proved on disk by injected-marker grep plushash-objectagainst the HEAD blobs; restore proved by emptygit diff HEAD, both hashes back, marker count 0.⭐ And the no-rebuild reasoning is stated rather than assumed: the mutated text is each test file's own fixture (vitest compiles it from source), while the subject under test resolves through
dist, built beforehand and untouched — so no dist preflight applies. Knowing when a dist preflight is and is not owed is the part most reports get wrong in the other direction.Landing
⏳ Flip and enqueue held pending CI convergence. Path fork clean — two test files under
packages/runtime/src/sandbox/andpackages/cli/src/; no governed surface. Goes to the queue on a full green — every check, ⛔ not the required subset.Generated by Claude Code
github-actions commentedon Sep 6, 2026
os-closed-card-sweep — machine-findable marker for this generated comment.
Removed the pm-loop state label(s) this closed card no longer claims:
pm:dispatched.5ea2e38820, merged intomain.tests,domain:cli,priority:p3— ownership, priority and outcome are not state claims.A state label claims work is in flight. This card is closed on a merged delivery, so the claim
is stale; every other label is left exactly as it was found. Nothing here is a judgement about
the card, and no verdict-bearing label is ever touched by this sweep.
posted by half-state-patrol run 34005012908 · trigger
scheduleGenerated by Claude Code