fix(lint): state the measured refusal in the three write-set rule messages - #14243

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-13858-write-set-message-staleness
Sep 1, 2026
Merged

fix(lint): state the measured refusal in the three write-set rule messages#14243
baozhoutao merged 1 commit into
mainfrom
claude/issue-13858-write-set-message-staleness

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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, real ObjectQL engine, the real AutomationEngine with the real builtin CRUD node executors, real @objectstack/driver-sql (better-sqlite3, real table) and real @objectstack/driver-memory.

pathdriver-sqldriver-memory
hook body ctx.api.object('deal').update({ stagee })INVALID_FIELD / 400INVALID_FIELD / 400
action body ctx.api.object('deal').update({ stagee })INVALID_FIELD / 400INVALID_FIELD / 400
flow create_record / update_recordfields mapINVALID_FIELD / 400INVALID_FIELD / 400

Every run answered Unknown field 'stagee' on object 'deal'. Nothing was stored on either family: no row after a refused create_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:

PATH1 (hook) SandboxError code=INVALID_FIELD status=400
innerMessage: Unknown field 'stagee' on object 'deal'
message: hook 'note_touches_deal' threw: Error: Unknown field 'stagee' on object 'deal'
PATH2 (action) SandboxError code=INVALID_FIELD status=400
message: action 'touch_deal' threw: Error: Unknown field 'stagee' on object 'deal'
PATH3 (flow) run success=false
Node 'mk' failed: create_record(deal) failed: Unknown field 'stagee' on object 'deal'
Node 'up' failed: update_record(deal) failed: Unknown field 'stagee' on object 'deal'
engine envelope for the identical call: code=INVALID_FIELD status=400 field=stagee

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.input sibling 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:

  • hook — the refusal escapes the body and fails the operation that triggered the hook;
  • action — the refusal escapes the body and fails the action;
  • flow node — the refusal is whole, so 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 this rule still gates at error; 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-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. No packages/runtime/** changes are committed.

Verification

All of it run against the final commit e46c976d, on a clean tree (git status --porcelain empty). Exit codes captured before any pipe, per this repo's false-green rule.

  • pnpm --filter @objectstack/lint test88 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-wide eslint . --no-inline-config) — exit 0.
  • Gate family derived from the actual diff, not recalled: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack → 33 families (29 by path + 6 by change kind, 2 shared), harvested with --commands so 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-measure re-runs tsc per ledger entry, 27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number, and surplus: 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 frozen TEST_DEBT of 16.

    Worth stating plainly, because the package-level typecheck green does not say this: packages/lint/tsconfig.json excludes **/*.test.ts, confirmed by tsc --listFiles (0 hits for all three edited test files). That exclusion is ledgered, not a new gap — check:type-check-coverage carries @objectstack/lint in TEST_DEBT and 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.mjs has 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 their HEAD blobs; the retired schemaless phrasing measurably back in each file) rather than inferred from an editor's exit code. Result:

Test Files 3 failed (3)
Tests 3 failed | 122 passed (125)
× validate-hook-body-writes › states the measured refusal … no driver split
× validate-action-body-writes › states the measured refusal … no driver split
× validate-flow-node-writes › states the measured refusal … no driver split

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 HEAD empty, git status --porcelain empty, and each file's on-disk git hash-object equal to its HEAD blob.

Out of scope

The ctx.api and flow-node paths now have no runtime pin of their own. undeclared-field-write-driver-split.integration.test.ts pins 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's packages/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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 5 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 1e130127c66242d8bca244aebcabe9f305a31aefpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 022fe2a7c499edbe6fe0a2d98811bd7f0559b43d — the merge of head e46c976db4598ae364733f815f2ffded2da1a7d2 into base 1e130127c66242d8bca244aebcabe9f305a31aef, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 1, 2026 16:09
@baozhoutao
baozhoutao added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 4b2cbf7Sep 1, 2026
40 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-13858-write-set-message-staleness branch September 1, 2026 16:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, '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

fix(lint): state the measured refusal in the three write-set rule messages - #14243

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-13858-write-set-message-staleness
Sep 1, 2026
Merged

fix(lint): state the measured refusal in the three write-set rule messages#14243
baozhoutao merged 1 commit into
mainfrom
claude/issue-13858-write-set-message-staleness

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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, real ObjectQL engine, the real AutomationEngine with the real builtin CRUD node executors, real @objectstack/driver-sql (better-sqlite3, real table) and real @objectstack/driver-memory.

pathdriver-sqldriver-memory
hook body ctx.api.object('deal').update({ stagee })INVALID_FIELD / 400INVALID_FIELD / 400
action body ctx.api.object('deal').update({ stagee })INVALID_FIELD / 400INVALID_FIELD / 400
flow create_record / update_recordfields mapINVALID_FIELD / 400INVALID_FIELD / 400

Every run answered Unknown field 'stagee' on object 'deal'. Nothing was stored on either family: no row after a refused create_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:

PATH1 (hook) SandboxError code=INVALID_FIELD status=400
innerMessage: Unknown field 'stagee' on object 'deal'
message: hook 'note_touches_deal' threw: Error: Unknown field 'stagee' on object 'deal'
PATH2 (action) SandboxError code=INVALID_FIELD status=400
message: action 'touch_deal' threw: Error: Unknown field 'stagee' on object 'deal'
PATH3 (flow) run success=false
Node 'mk' failed: create_record(deal) failed: Unknown field 'stagee' on object 'deal'
Node 'up' failed: update_record(deal) failed: Unknown field 'stagee' on object 'deal'
engine envelope for the identical call: code=INVALID_FIELD status=400 field=stagee

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.input sibling 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:

  • hook — the refusal escapes the body and fails the operation that triggered the hook;
  • action — the refusal escapes the body and fails the action;
  • flow node — the refusal is whole, so 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 this rule still gates at error; 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-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. No packages/runtime/** changes are committed.

Verification

All of it run against the final commit e46c976d, on a clean tree (git status --porcelain empty). Exit codes captured before any pipe, per this repo's false-green rule.

  • pnpm --filter @objectstack/lint test88 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-wide eslint . --no-inline-config) — exit 0.
  • Gate family derived from the actual diff, not recalled: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack → 33 families (29 by path + 6 by change kind, 2 shared), harvested with --commands so 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-measure re-runs tsc per ledger entry, 27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number, and surplus: 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 frozen TEST_DEBT of 16.

    Worth stating plainly, because the package-level typecheck green does not say this: packages/lint/tsconfig.json excludes **/*.test.ts, confirmed by tsc --listFiles (0 hits for all three edited test files). That exclusion is ledgered, not a new gap — check:type-check-coverage carries @objectstack/lint in TEST_DEBT and 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.mjs has 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 their HEAD blobs; the retired schemaless phrasing measurably back in each file) rather than inferred from an editor's exit code. Result:

Test Files 3 failed (3)
Tests 3 failed | 122 passed (125)
× validate-hook-body-writes › states the measured refusal … no driver split
× validate-action-body-writes › states the measured refusal … no driver split
× validate-flow-node-writes › states the measured refusal … no driver split

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 HEAD empty, git status --porcelain empty, and each file's on-disk git hash-object equal to its HEAD blob.

Out of scope

The ctx.api and flow-node paths now have no runtime pin of their own. undeclared-field-write-driver-split.integration.test.ts pins 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's packages/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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 5 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 1e130127c66242d8bca244aebcabe9f305a31aefpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 022fe2a7c499edbe6fe0a2d98811bd7f0559b43d — the merge of head e46c976db4598ae364733f815f2ffded2da1a7d2 into base 1e130127c66242d8bca244aebcabe9f305a31aef, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 1, 2026 16:09
@baozhoutao
baozhoutao added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 4b2cbf7Sep 1, 2026
40 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-13858-write-set-message-staleness branch September 1, 2026 16:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, '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

fix(lint): state the measured refusal in the three write-set rule messages - #14243

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-13858-write-set-message-staleness
Sep 1, 2026
Merged

fix(lint): state the measured refusal in the three write-set rule messages#14243
baozhoutao merged 1 commit into
mainfrom
claude/issue-13858-write-set-message-staleness

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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, real ObjectQL engine, the real AutomationEngine with the real builtin CRUD node executors, real @objectstack/driver-sql (better-sqlite3, real table) and real @objectstack/driver-memory.

pathdriver-sqldriver-memory
hook body ctx.api.object('deal').update({ stagee })INVALID_FIELD / 400INVALID_FIELD / 400
action body ctx.api.object('deal').update({ stagee })INVALID_FIELD / 400INVALID_FIELD / 400
flow create_record / update_recordfields mapINVALID_FIELD / 400INVALID_FIELD / 400

Every run answered Unknown field 'stagee' on object 'deal'. Nothing was stored on either family: no row after a refused create_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:

PATH1 (hook) SandboxError code=INVALID_FIELD status=400
innerMessage: Unknown field 'stagee' on object 'deal'
message: hook 'note_touches_deal' threw: Error: Unknown field 'stagee' on object 'deal'
PATH2 (action) SandboxError code=INVALID_FIELD status=400
message: action 'touch_deal' threw: Error: Unknown field 'stagee' on object 'deal'
PATH3 (flow) run success=false
Node 'mk' failed: create_record(deal) failed: Unknown field 'stagee' on object 'deal'
Node 'up' failed: update_record(deal) failed: Unknown field 'stagee' on object 'deal'
engine envelope for the identical call: code=INVALID_FIELD status=400 field=stagee

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.input sibling 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:

  • hook — the refusal escapes the body and fails the operation that triggered the hook;
  • action — the refusal escapes the body and fails the action;
  • flow node — the refusal is whole, so 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 this rule still gates at error; 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-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. No packages/runtime/** changes are committed.

Verification

All of it run against the final commit e46c976d, on a clean tree (git status --porcelain empty). Exit codes captured before any pipe, per this repo's false-green rule.

  • pnpm --filter @objectstack/lint test88 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-wide eslint . --no-inline-config) — exit 0.
  • Gate family derived from the actual diff, not recalled: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack → 33 families (29 by path + 6 by change kind, 2 shared), harvested with --commands so 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-measure re-runs tsc per ledger entry, 27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number, and surplus: 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 frozen TEST_DEBT of 16.

    Worth stating plainly, because the package-level typecheck green does not say this: packages/lint/tsconfig.json excludes **/*.test.ts, confirmed by tsc --listFiles (0 hits for all three edited test files). That exclusion is ledgered, not a new gap — check:type-check-coverage carries @objectstack/lint in TEST_DEBT and 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.mjs has 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 their HEAD blobs; the retired schemaless phrasing measurably back in each file) rather than inferred from an editor's exit code. Result:

Test Files 3 failed (3)
Tests 3 failed | 122 passed (125)
× validate-hook-body-writes › states the measured refusal … no driver split
× validate-action-body-writes › states the measured refusal … no driver split
× validate-flow-node-writes › states the measured refusal … no driver split

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 HEAD empty, git status --porcelain empty, and each file's on-disk git hash-object equal to its HEAD blob.

Out of scope

The ctx.api and flow-node paths now have no runtime pin of their own. undeclared-field-write-driver-split.integration.test.ts pins 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's packages/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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 5 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 1e130127c66242d8bca244aebcabe9f305a31aefpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 022fe2a7c499edbe6fe0a2d98811bd7f0559b43d — the merge of head e46c976db4598ae364733f815f2ffded2da1a7d2 into base 1e130127c66242d8bca244aebcabe9f305a31aef, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 1, 2026 16:09
@baozhoutao
baozhoutao added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 4b2cbf7Sep 1, 2026
40 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-13858-write-set-message-staleness branch September 1, 2026 16:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, '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

fix(lint): state the measured refusal in the three write-set rule messages - #14243

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-13858-write-set-message-staleness
Sep 1, 2026
Merged

fix(lint): state the measured refusal in the three write-set rule messages#14243
baozhoutao merged 1 commit into
mainfrom
claude/issue-13858-write-set-message-staleness

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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, real ObjectQL engine, the real AutomationEngine with the real builtin CRUD node executors, real @objectstack/driver-sql (better-sqlite3, real table) and real @objectstack/driver-memory.

pathdriver-sqldriver-memory
hook body ctx.api.object('deal').update({ stagee })INVALID_FIELD / 400INVALID_FIELD / 400
action body ctx.api.object('deal').update({ stagee })INVALID_FIELD / 400INVALID_FIELD / 400
flow create_record / update_recordfields mapINVALID_FIELD / 400INVALID_FIELD / 400

Every run answered Unknown field 'stagee' on object 'deal'. Nothing was stored on either family: no row after a refused create_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:

PATH1 (hook) SandboxError code=INVALID_FIELD status=400
innerMessage: Unknown field 'stagee' on object 'deal'
message: hook 'note_touches_deal' threw: Error: Unknown field 'stagee' on object 'deal'
PATH2 (action) SandboxError code=INVALID_FIELD status=400
message: action 'touch_deal' threw: Error: Unknown field 'stagee' on object 'deal'
PATH3 (flow) run success=false
Node 'mk' failed: create_record(deal) failed: Unknown field 'stagee' on object 'deal'
Node 'up' failed: update_record(deal) failed: Unknown field 'stagee' on object 'deal'
engine envelope for the identical call: code=INVALID_FIELD status=400 field=stagee

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.input sibling 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:

  • hook — the refusal escapes the body and fails the operation that triggered the hook;
  • action — the refusal escapes the body and fails the action;
  • flow node — the refusal is whole, so 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 this rule still gates at error; 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-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. No packages/runtime/** changes are committed.

Verification

All of it run against the final commit e46c976d, on a clean tree (git status --porcelain empty). Exit codes captured before any pipe, per this repo's false-green rule.

  • pnpm --filter @objectstack/lint test88 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-wide eslint . --no-inline-config) — exit 0.
  • Gate family derived from the actual diff, not recalled: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack → 33 families (29 by path + 6 by change kind, 2 shared), harvested with --commands so 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-measure re-runs tsc per ledger entry, 27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number, and surplus: 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 frozen TEST_DEBT of 16.

    Worth stating plainly, because the package-level typecheck green does not say this: packages/lint/tsconfig.json excludes **/*.test.ts, confirmed by tsc --listFiles (0 hits for all three edited test files). That exclusion is ledgered, not a new gap — check:type-check-coverage carries @objectstack/lint in TEST_DEBT and 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.mjs has 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 their HEAD blobs; the retired schemaless phrasing measurably back in each file) rather than inferred from an editor's exit code. Result:

Test Files 3 failed (3)
Tests 3 failed | 122 passed (125)
× validate-hook-body-writes › states the measured refusal … no driver split
× validate-action-body-writes › states the measured refusal … no driver split
× validate-flow-node-writes › states the measured refusal … no driver split

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 HEAD empty, git status --porcelain empty, and each file's on-disk git hash-object equal to its HEAD blob.

Out of scope

The ctx.api and flow-node paths now have no runtime pin of their own. undeclared-field-write-driver-split.integration.test.ts pins 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's packages/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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 5 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 1e130127c66242d8bca244aebcabe9f305a31aefpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 022fe2a7c499edbe6fe0a2d98811bd7f0559b43d — the merge of head e46c976db4598ae364733f815f2ffded2da1a7d2 into base 1e130127c66242d8bca244aebcabe9f305a31aef, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 1, 2026 16:09
@baozhoutao
baozhoutao added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 4b2cbf7Sep 1, 2026
40 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-13858-write-set-message-staleness branch September 1, 2026 16:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, '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

fix(lint): state the measured refusal in the three write-set rule messages - #14243

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-13858-write-set-message-staleness
Sep 1, 2026
Merged

fix(lint): state the measured refusal in the three write-set rule messages#14243
baozhoutao merged 1 commit into
mainfrom
claude/issue-13858-write-set-message-staleness

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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, real ObjectQL engine, the real AutomationEngine with the real builtin CRUD node executors, real @objectstack/driver-sql (better-sqlite3, real table) and real @objectstack/driver-memory.

pathdriver-sqldriver-memory
hook body ctx.api.object('deal').update({ stagee })INVALID_FIELD / 400INVALID_FIELD / 400
action body ctx.api.object('deal').update({ stagee })INVALID_FIELD / 400INVALID_FIELD / 400
flow create_record / update_recordfields mapINVALID_FIELD / 400INVALID_FIELD / 400

Every run answered Unknown field 'stagee' on object 'deal'. Nothing was stored on either family: no row after a refused create_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:

PATH1 (hook) SandboxError code=INVALID_FIELD status=400
innerMessage: Unknown field 'stagee' on object 'deal'
message: hook 'note_touches_deal' threw: Error: Unknown field 'stagee' on object 'deal'
PATH2 (action) SandboxError code=INVALID_FIELD status=400
message: action 'touch_deal' threw: Error: Unknown field 'stagee' on object 'deal'
PATH3 (flow) run success=false
Node 'mk' failed: create_record(deal) failed: Unknown field 'stagee' on object 'deal'
Node 'up' failed: update_record(deal) failed: Unknown field 'stagee' on object 'deal'
engine envelope for the identical call: code=INVALID_FIELD status=400 field=stagee

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.input sibling 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:

  • hook — the refusal escapes the body and fails the operation that triggered the hook;
  • action — the refusal escapes the body and fails the action;
  • flow node — the refusal is whole, so 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 this rule still gates at error; 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-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. No packages/runtime/** changes are committed.

Verification

All of it run against the final commit e46c976d, on a clean tree (git status --porcelain empty). Exit codes captured before any pipe, per this repo's false-green rule.

  • pnpm --filter @objectstack/lint test88 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-wide eslint . --no-inline-config) — exit 0.
  • Gate family derived from the actual diff, not recalled: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack → 33 families (29 by path + 6 by change kind, 2 shared), harvested with --commands so 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-measure re-runs tsc per ledger entry, 27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number, and surplus: 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 frozen TEST_DEBT of 16.

    Worth stating plainly, because the package-level typecheck green does not say this: packages/lint/tsconfig.json excludes **/*.test.ts, confirmed by tsc --listFiles (0 hits for all three edited test files). That exclusion is ledgered, not a new gap — check:type-check-coverage carries @objectstack/lint in TEST_DEBT and 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.mjs has 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 their HEAD blobs; the retired schemaless phrasing measurably back in each file) rather than inferred from an editor's exit code. Result:

Test Files 3 failed (3)
Tests 3 failed | 122 passed (125)
× validate-hook-body-writes › states the measured refusal … no driver split
× validate-action-body-writes › states the measured refusal … no driver split
× validate-flow-node-writes › states the measured refusal … no driver split

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 HEAD empty, git status --porcelain empty, and each file's on-disk git hash-object equal to its HEAD blob.

Out of scope

The ctx.api and flow-node paths now have no runtime pin of their own. undeclared-field-write-driver-split.integration.test.ts pins 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's packages/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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 5 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 1e130127c66242d8bca244aebcabe9f305a31aefpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 022fe2a7c499edbe6fe0a2d98811bd7f0559b43d — the merge of head e46c976db4598ae364733f815f2ffded2da1a7d2 into base 1e130127c66242d8bca244aebcabe9f305a31aef, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 1, 2026 16:09
@baozhoutao
baozhoutao added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 4b2cbf7Sep 1, 2026
40 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-13858-write-set-message-staleness branch September 1, 2026 16:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, '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

fix(lint): state the measured refusal in the three write-set rule messages - #14243

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-13858-write-set-message-staleness
Sep 1, 2026
Merged

fix(lint): state the measured refusal in the three write-set rule messages#14243
baozhoutao merged 1 commit into
mainfrom
claude/issue-13858-write-set-message-staleness

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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, real ObjectQL engine, the real AutomationEngine with the real builtin CRUD node executors, real @objectstack/driver-sql (better-sqlite3, real table) and real @objectstack/driver-memory.

pathdriver-sqldriver-memory
hook body ctx.api.object('deal').update({ stagee })INVALID_FIELD / 400INVALID_FIELD / 400
action body ctx.api.object('deal').update({ stagee })INVALID_FIELD / 400INVALID_FIELD / 400
flow create_record / update_recordfields mapINVALID_FIELD / 400INVALID_FIELD / 400

Every run answered Unknown field 'stagee' on object 'deal'. Nothing was stored on either family: no row after a refused create_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:

PATH1 (hook) SandboxError code=INVALID_FIELD status=400
innerMessage: Unknown field 'stagee' on object 'deal'
message: hook 'note_touches_deal' threw: Error: Unknown field 'stagee' on object 'deal'
PATH2 (action) SandboxError code=INVALID_FIELD status=400
message: action 'touch_deal' threw: Error: Unknown field 'stagee' on object 'deal'
PATH3 (flow) run success=false
Node 'mk' failed: create_record(deal) failed: Unknown field 'stagee' on object 'deal'
Node 'up' failed: update_record(deal) failed: Unknown field 'stagee' on object 'deal'
engine envelope for the identical call: code=INVALID_FIELD status=400 field=stagee

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.input sibling 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:

  • hook — the refusal escapes the body and fails the operation that triggered the hook;
  • action — the refusal escapes the body and fails the action;
  • flow node — the refusal is whole, so 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 this rule still gates at error; 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-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. No packages/runtime/** changes are committed.

Verification

All of it run against the final commit e46c976d, on a clean tree (git status --porcelain empty). Exit codes captured before any pipe, per this repo's false-green rule.

  • pnpm --filter @objectstack/lint test88 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-wide eslint . --no-inline-config) — exit 0.
  • Gate family derived from the actual diff, not recalled: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack → 33 families (29 by path + 6 by change kind, 2 shared), harvested with --commands so 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-measure re-runs tsc per ledger entry, 27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number, and surplus: 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 frozen TEST_DEBT of 16.

    Worth stating plainly, because the package-level typecheck green does not say this: packages/lint/tsconfig.json excludes **/*.test.ts, confirmed by tsc --listFiles (0 hits for all three edited test files). That exclusion is ledgered, not a new gap — check:type-check-coverage carries @objectstack/lint in TEST_DEBT and 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.mjs has 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 their HEAD blobs; the retired schemaless phrasing measurably back in each file) rather than inferred from an editor's exit code. Result:

Test Files 3 failed (3)
Tests 3 failed | 122 passed (125)
× validate-hook-body-writes › states the measured refusal … no driver split
× validate-action-body-writes › states the measured refusal … no driver split
× validate-flow-node-writes › states the measured refusal … no driver split

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 HEAD empty, git status --porcelain empty, and each file's on-disk git hash-object equal to its HEAD blob.

Out of scope

The ctx.api and flow-node paths now have no runtime pin of their own. undeclared-field-write-driver-split.integration.test.ts pins 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's packages/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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 5 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 1e130127c66242d8bca244aebcabe9f305a31aefpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 022fe2a7c499edbe6fe0a2d98811bd7f0559b43d — the merge of head e46c976db4598ae364733f815f2ffded2da1a7d2 into base 1e130127c66242d8bca244aebcabe9f305a31aef, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 1, 2026 16:09
@baozhoutao
baozhoutao added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 4b2cbf7Sep 1, 2026
40 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-13858-write-set-message-staleness branch September 1, 2026 16:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, '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

fix(lint): state the measured refusal in the three write-set rule messages - #14243

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-13858-write-set-message-staleness
Sep 1, 2026
Merged

fix(lint): state the measured refusal in the three write-set rule messages#14243
baozhoutao merged 1 commit into
mainfrom
claude/issue-13858-write-set-message-staleness

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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, real ObjectQL engine, the real AutomationEngine with the real builtin CRUD node executors, real @objectstack/driver-sql (better-sqlite3, real table) and real @objectstack/driver-memory.

pathdriver-sqldriver-memory
hook body ctx.api.object('deal').update({ stagee })INVALID_FIELD / 400INVALID_FIELD / 400
action body ctx.api.object('deal').update({ stagee })INVALID_FIELD / 400INVALID_FIELD / 400
flow create_record / update_recordfields mapINVALID_FIELD / 400INVALID_FIELD / 400

Every run answered Unknown field 'stagee' on object 'deal'. Nothing was stored on either family: no row after a refused create_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:

PATH1 (hook) SandboxError code=INVALID_FIELD status=400
innerMessage: Unknown field 'stagee' on object 'deal'
message: hook 'note_touches_deal' threw: Error: Unknown field 'stagee' on object 'deal'
PATH2 (action) SandboxError code=INVALID_FIELD status=400
message: action 'touch_deal' threw: Error: Unknown field 'stagee' on object 'deal'
PATH3 (flow) run success=false
Node 'mk' failed: create_record(deal) failed: Unknown field 'stagee' on object 'deal'
Node 'up' failed: update_record(deal) failed: Unknown field 'stagee' on object 'deal'
engine envelope for the identical call: code=INVALID_FIELD status=400 field=stagee

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.input sibling 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:

  • hook — the refusal escapes the body and fails the operation that triggered the hook;
  • action — the refusal escapes the body and fails the action;
  • flow node — the refusal is whole, so 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 this rule still gates at error; 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-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. No packages/runtime/** changes are committed.

Verification

All of it run against the final commit e46c976d, on a clean tree (git status --porcelain empty). Exit codes captured before any pipe, per this repo's false-green rule.

  • pnpm --filter @objectstack/lint test88 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-wide eslint . --no-inline-config) — exit 0.
  • Gate family derived from the actual diff, not recalled: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack → 33 families (29 by path + 6 by change kind, 2 shared), harvested with --commands so 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-measure re-runs tsc per ledger entry, 27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number, and surplus: 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 frozen TEST_DEBT of 16.

    Worth stating plainly, because the package-level typecheck green does not say this: packages/lint/tsconfig.json excludes **/*.test.ts, confirmed by tsc --listFiles (0 hits for all three edited test files). That exclusion is ledgered, not a new gap — check:type-check-coverage carries @objectstack/lint in TEST_DEBT and 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.mjs has 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 their HEAD blobs; the retired schemaless phrasing measurably back in each file) rather than inferred from an editor's exit code. Result:

Test Files 3 failed (3)
Tests 3 failed | 122 passed (125)
× validate-hook-body-writes › states the measured refusal … no driver split
× validate-action-body-writes › states the measured refusal … no driver split
× validate-flow-node-writes › states the measured refusal … no driver split

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 HEAD empty, git status --porcelain empty, and each file's on-disk git hash-object equal to its HEAD blob.

Out of scope

The ctx.api and flow-node paths now have no runtime pin of their own. undeclared-field-write-driver-split.integration.test.ts pins 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's packages/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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 5 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 1e130127c66242d8bca244aebcabe9f305a31aefpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 022fe2a7c499edbe6fe0a2d98811bd7f0559b43d — the merge of head e46c976db4598ae364733f815f2ffded2da1a7d2 into base 1e130127c66242d8bca244aebcabe9f305a31aef, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 1, 2026 16:09
@baozhoutao
baozhoutao added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 4b2cbf7Sep 1, 2026
40 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-13858-write-set-message-staleness branch September 1, 2026 16:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, '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

fix(lint): state the measured refusal in the three write-set rule messages - #14243

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-13858-write-set-message-staleness
Sep 1, 2026
Merged

fix(lint): state the measured refusal in the three write-set rule messages#14243
baozhoutao merged 1 commit into
mainfrom
claude/issue-13858-write-set-message-staleness

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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, real ObjectQL engine, the real AutomationEngine with the real builtin CRUD node executors, real @objectstack/driver-sql (better-sqlite3, real table) and real @objectstack/driver-memory.

pathdriver-sqldriver-memory
hook body ctx.api.object('deal').update({ stagee })INVALID_FIELD / 400INVALID_FIELD / 400
action body ctx.api.object('deal').update({ stagee })INVALID_FIELD / 400INVALID_FIELD / 400
flow create_record / update_recordfields mapINVALID_FIELD / 400INVALID_FIELD / 400

Every run answered Unknown field 'stagee' on object 'deal'. Nothing was stored on either family: no row after a refused create_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:

PATH1 (hook) SandboxError code=INVALID_FIELD status=400
innerMessage: Unknown field 'stagee' on object 'deal'
message: hook 'note_touches_deal' threw: Error: Unknown field 'stagee' on object 'deal'
PATH2 (action) SandboxError code=INVALID_FIELD status=400
message: action 'touch_deal' threw: Error: Unknown field 'stagee' on object 'deal'
PATH3 (flow) run success=false
Node 'mk' failed: create_record(deal) failed: Unknown field 'stagee' on object 'deal'
Node 'up' failed: update_record(deal) failed: Unknown field 'stagee' on object 'deal'
engine envelope for the identical call: code=INVALID_FIELD status=400 field=stagee

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.input sibling 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:

  • hook — the refusal escapes the body and fails the operation that triggered the hook;
  • action — the refusal escapes the body and fails the action;
  • flow node — the refusal is whole, so 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 this rule still gates at error; 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-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. No packages/runtime/** changes are committed.

Verification

All of it run against the final commit e46c976d, on a clean tree (git status --porcelain empty). Exit codes captured before any pipe, per this repo's false-green rule.

  • pnpm --filter @objectstack/lint test88 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-wide eslint . --no-inline-config) — exit 0.
  • Gate family derived from the actual diff, not recalled: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack → 33 families (29 by path + 6 by change kind, 2 shared), harvested with --commands so 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-measure re-runs tsc per ledger entry, 27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number, and surplus: 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 frozen TEST_DEBT of 16.

    Worth stating plainly, because the package-level typecheck green does not say this: packages/lint/tsconfig.json excludes **/*.test.ts, confirmed by tsc --listFiles (0 hits for all three edited test files). That exclusion is ledgered, not a new gap — check:type-check-coverage carries @objectstack/lint in TEST_DEBT and 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.mjs has 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 their HEAD blobs; the retired schemaless phrasing measurably back in each file) rather than inferred from an editor's exit code. Result:

Test Files 3 failed (3)
Tests 3 failed | 122 passed (125)
× validate-hook-body-writes › states the measured refusal … no driver split
× validate-action-body-writes › states the measured refusal … no driver split
× validate-flow-node-writes › states the measured refusal … no driver split

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 HEAD empty, git status --porcelain empty, and each file's on-disk git hash-object equal to its HEAD blob.

Out of scope

The ctx.api and flow-node paths now have no runtime pin of their own. undeclared-field-write-driver-split.integration.test.ts pins 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's packages/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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 5 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 1e130127c66242d8bca244aebcabe9f305a31aefpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 022fe2a7c499edbe6fe0a2d98811bd7f0559b43d — the merge of head e46c976db4598ae364733f815f2ffded2da1a7d2 into base 1e130127c66242d8bca244aebcabe9f305a31aef, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 1, 2026 16:09
@baozhoutao
baozhoutao added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 4b2cbf7Sep 1, 2026
40 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-13858-write-set-message-staleness branch September 1, 2026 16:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude