Uh oh!
There was an error while loading. Please reload this page.
fix(lint): state the measured refusal in the three write-set rule messages - #14243
Conversation
…sages The ctx.api branch of validate-hook-body-writes, validate-action-body-writes and validate-flow-node-writes all described a driver-dependent outcome for an undeclared write — a driver-level error on SQL, a silently persisted stray key on a schemaless driver. For the paths these three rules judge, that stopped being true when the declared-field door landed: all three carry a CALLER-supplied payload (ctx.api is a ScopedContext over the running engine; a flow node hands its fields map to the data engine directly), and the door refuses a caller-named undeclared key before any statement is built. Measured on all three paths and both driver families before the prose was rewritten — real QuickJS sandbox, real ObjectQL engine, real AutomationEngine with the builtin CRUD node executors, driver-sql (better-sqlite3) and driver-memory: every run answered INVALID_FIELD / 400, "Unknown field 'stagee' on object 'deal'", nothing was stored, and the schemaless family kept no shadow column. Message text only — rule ids, severities, match sets and hints unchanged. unprovisionedAnchorWriteConsequence() 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. Each corrected message is pinned, positively on the new wording and negatively on the retired driver-split phrasing. The stale file headers that asserted the same split are corrected alongside the sentences they explain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 022fe2a7c499edbe6fe0a2d98811bd7f0559b43d && git checkout 022fe2a7c499edbe6fe0a2d98811bd7f0559b43d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1e130127c66242d8bca244aebcabe9f305a31aef e46c976db4598ae364733f815f2ffded2da1a7d2 && git checkout -B drift-repro 1e130127c66242d8bca244aebcabe9f305a31aef && git merge --no-ff e46c976db4598ae364733f815f2ffded2da1a7d2
node scripts/docs-audit/affected-docs.mjs --json 1e130127c66242d8bca244aebcabe9f305a31aef |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13858
Three lint rule messages told authors that an undeclared field write has a driver-dependent outcome — "on a SQL driver the whole call fails with a driver-level error; on a schemaless driver (memory, MongoDB) the stray key is persisted". For the paths those three rules actually judge, that has not been true since the declared-field door landed (#8682 insert, #8738 update).
The triage comment made a reproduction the hard gate: measure the three paths first, do not rewrite the prose from the call shape. That was done before a word was changed.
The measurement
Six runs — three paths, two driver families each. Real QuickJS sandbox, real hook body and real action body through
hookBodyRunnerFactory/actionBodyRunnerFactory, realObjectQLengine, the realAutomationEnginewith the real builtin CRUD node executors, real@objectstack/driver-sql(better-sqlite3, real table) and real@objectstack/driver-memory.ctx.api.object('deal').update({ stagee })INVALID_FIELD/ 400INVALID_FIELD/ 400ctx.api.object('deal').update({ stagee })INVALID_FIELD/ 400INVALID_FIELD/ 400create_record/update_recordfieldsmapINVALID_FIELD/ 400INVALID_FIELD/ 400Every run answered
Unknown field 'stagee' on object 'deal'. Nothing was stored on either family: no row after a refusedcreate_record, an untouched row after a refused update, and no shadow column on the schemaless family — the half of the old message the runtime no longer delivers.Verdict: no fork. All three are refused by the declared-field door before any driver is reached, so all three messages were rewritten to the measured behaviour.
Observed envelopes, verbatim:
Identical on both families in every row. The reproduction ran as a scratch measurement and is not committed —
packages/runtime/**is another lane's package, and the triage fenced committed runtime changes out. The tree is clean of it; see Out of scope for the pin that is genuinely warranted there.What changed
Message text only. Rule ids, severities, match sets and hints are unchanged, and no finding changes shape — but the rule file's own header states why the prose is governed: "a lint that misdescribes the failure it is warning about teaches the wrong debugging instinct".
Each message now names the refusal in the vocabulary the
ctx.inputsibling landed with one branch over (REFUSED at run time — INVALID_FIELD / 400, identically on every driver), says why the door and not a driver answers (the payload is caller-supplied), and keeps its own blast radius:create_recordnever creates the row, and the step fails the run. That last clause is why this rule still gates aterror; the severity is untouched.Each corrected sentence is pinned in a dedicated test, positively on the new wording and negatively on the retired driver-split phrasing, following the message-pinning idiom PR #14202 used in this package this week.
Beyond the three sentences, and named here rather than left silent: the three file headers asserted the same retired driver split in the prose that explains those very sentences — the flow file's header carried it as its severity justification, complete with a "measured, not inferred" claim. Leaving them would have reproduced the exact defect this card names one screen above the corrected message, so they were corrected in the same edit, from the same measurement. No behaviour rides on them.
Scope fences held
unprovisionedAnchorWriteConsequence()is untouched, as the card and the triage both require: an ADR-0015externalobject'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. Nopackages/runtime/**changes are committed.Verification
All of it run against the final commit
e46c976d, on a clean tree (git status --porcelainempty). Exit codes captured before any pipe, per this repo's false-green rule.pnpm --filter @objectstack/lint test— 88 files / 2473 tests passed, exit 0. Re-run after the final commit, so the green is about the tree the PR actually carries.pnpm --filter @objectstack/lint typecheck— exit 0.pnpm lint(repo-wideeslint . --no-inline-config) — exit 0.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack→ 33 families (29 by path + 6 by change kind, 2 shared), harvested with--commandsso neither spelling nor the convention block could be dropped. All 33 green.Two of them needed the workspace closure built first and returned exit 3 (each gate's own documented NOT MEASURED branch, never a red) until it was; after
turbo run build --filter='./packages/*' --filter='./packages/*/*'both were run for real:check:dual-build-cjs-loads— exit 0.check:type-check-debt— exit 0. This is the one that matters here:--re-measurere-runs tsc per ledger entry,27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number, andsurplus: none — every entry sits exactly at its measurement, so any new error is red. So the three edited test files were type-checked after all, with zero new errors against@objectstack/lint's frozenTEST_DEBTof 16.Worth stating plainly, because the package-level
typecheckgreen does not say this:packages/lint/tsconfig.jsonexcludes**/*.test.ts, confirmed bytsc --listFiles(0 hits for all three edited test files). That exclusion is ledgered, not a new gap —check:type-check-coveragecarries@objectstack/lintinTEST_DEBTand none of its itemised files are ones this PR touches. The ledger re-measure above is what actually covers the new test code.check-test-completeness.mjshas no local reading by construction — it parses a test-run log CI tees into it, and its own output says so: "the local reading for this gate is NOT MEASURED. It is not a red, and there is nothing here to fix."Reverse verification
Direction predicted before running: the three new pins should go red against the pre-fix text, and nothing else should move.
The fix was committed first, then the three rule sources — and only the sources, never the test files — were restored to the merge base
66ecc50a9. The mutation was proved on disk (all three blob hashes differed from theirHEADblobs; the retiredschemalessphrasing measurably back in each file) rather than inferred from an editor's exit code. Result:Exactly three failures, exactly the three new pins — and the other 122 tests in those same files stayed green, which is the half that matters: no pre-existing test was depending on the retired wording.
Restored with
git checkout HEAD -- <path>(index and tree) and the restore proved, not assumed:git diff HEADempty,git status --porcelainempty, and each file's on-diskgit hash-objectequal to itsHEADblob.Out of scope
The
ctx.apiand flow-node paths now have no runtime pin of their own.undeclared-field-write-driver-split.integration.test.tspins the body-written key and the caller payload, but nothing pins the two paths measured here, so the sentences this PR just corrected could go stale again with every gate green — exactly the drift that file exists to prevent. Filed as #14241 rather than added, per the triage'spackages/runtime/**fence; the scratch harness that produced the table above is the shape it wants.Authored in session
https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV(recorded in prose because a body edit normalises the footer link below).Generated by Claude Code