fix(formula,plugin-security): the cross-class field-comparison refusal leads with its remedy, so REST callers read the fix (#20869) - #20972
Conversation
…remedy The matcher's refusal now opens with the fix and fits the REST client message bound whole (494 characters); the explain engine's copy puts the same remedy before its unbounded subject and diagnostic. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…edy on the wire The /data insert and find doors and POST /security/explain, through the real security layer on driver-sql: each wire message carries its producer's remedy, a long-names fixture keeps the remedy under the bound, and a short refusal of another class reaches the wire unchanged. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…rst refusal Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2aa7dc325e6a4db2dfb73a713af1db3ca4d6debc && git checkout 2aa7dc325e6a4db2dfb73a713af1db3ca4d6debc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4957ee5ef0e660fc9ee4525d83f13aa32ef8e969 dc440bff6bf9c731ce7f315ac8015de9624370b1 && git checkout -B drift-repro 4957ee5ef0e660fc9ee4525d83f13aa32ef8e969 && git merge --no-ff dc440bff6bf9c731ce7f315ac8015de9624370b1
node scripts/docs-audit/affected-docs.mjs --json 4957ee5ef0e660fc9ee4525d83f13aa32ef8e969 |
Contract reviewServed-tier: Inputs, and nothing else: card #20869 (body; comments 5912895835, 5920895653, 5921682938), #20355 with its comments (the withheld posture), #5423, PR #20972 (body, file list, net diff against ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #20869
Clause-②: no
What this changes
The refusal of a field-to-field comparison across comparison classes (
INVALID_FILTER/ 400) now opens with its remedy, on both of its producers. The REST door bounds a 4xx message by cutting its tail (CLIENT_MESSAGE_MAX = 500: 499 characters plus an ellipsis). Both producers wrote the remedy last, so no caller ever read it. The bound is not touched; that is #5423's decision. The producers change.packages/formula/src/matches-filter.ts,crossFieldClassError. The remedy sentence comes first, byte-identical to the one the message ended with. The diagnosis is shortened so the whole message is 494 characters and reaches the wire whole. Order: the remedy; what is refused (two columns with no shared class, and the classes); why it is refused; why the columns are withheld. It still names no column, operator or policy; the columns travel on the error's symbol key for the server log only.packages/plugins/plugin-security/src/explain-engine.ts,crossFieldRefusalForExplain. The remedy sentence, also byte-identical, moves before the subject and the diagnostic. Those have no length bound: object, field and policy names declare no maximum (SnakeCaseIdentifierSchema, the objectnameand the RLS policynameare regex-only), and the subject lists every refused policy. So no subject-first order keeps a trailing remedy for every policy; at index 0 it survives any length. The reason drops one redundant clause ("instead of judging a record": the same sentence already says explain "reports no verdict").Code, status and trigger are unchanged. Only the text moves and shortens.
Measured through the real handlers
ObjectQL on driver-sql (better-sqlite3),
SecurityPlugin,RestServerroute handlers, and a policyrecord.status != record.amount. "Remedy at" is the index where the remedy sentence starts.013f97df93)POST /data/:object(insert: the RLS write check)GET /data/:object(find)POST /security/explain, short namesPOST /security/explain, 60-character namesGET /data/:objectwith{ title: { $bogus: 1 } }A find never carries the matcher's message. Only the RLS write check (
security-plugin.ts,satisfiesCheck) and explain (matchUnderDeclaredColumns) handmatchesFilterConditionthe declared columns its class rule reads; the other runtime caller (objectqlhaving-filter.ts) passes none. On/dataa find answers driver-sql's own read refusal of the same comparison, 383 characters, which already reached the wire whole and states the rule ("compared as the same type class"). The matcher's text reaches/dataon an insert or an update. So the/datapin covers both: the insert carries the matcher's remedy, and the find carries its read refusal whole.Pins
packages/rest/src/cross-class-refusal-remedy-on-the-wire.test.ts(new, the real stack, both envelopes this family speaks):/datainsert: the wire message starts with the matcher's remedy, is under the bound, equals the thrown message, and names neither column;/datafind: the read refusal reaches the wire whole and states the same-class rule;POST /security/explain: the wire message starts with the explain remedy, is within the bound, and names the policy;packages/formula/src/matches-filter-cross-field-class.test.ts: the message starts with the remedy, is under 500, is the same for long column names, and keeps the clause order.packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts: every refused cell (5 predicates, read / update / delete, SQLite and sqlite-wasm) asserts the message starts with the remedy.packages/plugins/plugin-security/src/rls-check-cross-class-field-refused.test.ts: the pinned opening moves with the text.Red, then green. The new REST pin reads the producers through their
dist/(theresttoplugin-securitypair is unaliased and registered incheck:test-source-alias). At2cfaa6522f, against producer builds from BASE013f97df93(new-text markers: 0 in both dists): 3 failed (insert, explain, long names) and 2 passed (find, control). After rebuilding both producers from2cfaa6522f(markers: 1 in each): 5 passed.Verification (head
dc440bff6b, after mergingorigin/mainatf6ccca4a44)5fde18e296(before the merge):@objectstack/formula:test42 files, 1241 passed;typecheckOK, test-layer debt held;@objectstack/plugin-security: 149 files, 3227 passed, 23 skipped (the PostgreSQL legs, no server);typecheckOK;@objectstack/rest:--project local245 files, 4875 passed, 114 skipped;typecheckOK, 0 test-layer errors.dc440bff6b(the merge brought commits intorest): the formula pin file 23 passed; the three plugin-security cross-class files 150 passed, 23 skipped;@objectstack/rest--project local246 files, 4890 passed, 114 skipped.node scripts/pm/dispatch-gates.mjs --commandsatdc440bff6bderives 65 commands; all 65 exit 0.--ranreconciliation: "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN". Three of them (check:dual-build-cjs-loads,check:i18n,check:type-check-debt) first exited 3 (PREREQUISITE NOT MET: nothing measured). After the fullturbo run build --filter='./packages/*' --filter='./packages/*/*'(71 tasks), all three exit 0.dc440bff6b. ESLint's ownisPathIgnoredandcalculateConfigForFileput 6 of the 7 changed paths in its population; its config ignores the changeset.md.lintFilesover those 6 withallowInlineConfig: false(thelintscript's--no-inline-config), JSON formatter: 6 files, 0 errors, 0 warnings. The config enables no type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move a verdict on an untouched file. The repository-widepnpm lintbelongs to CI.Acceptance notes
fieldReferenceUnsupportedError: the driver has no field-to-field lowering at all). It is 538 characters from source, so the bound cuts it. Its remedy ("Compare against a literal value instead.") ends at 410 and survives; the cut drops the end of its withholding sentence. Not edited here. Source reading plus the bound's arithmetic; not measured through a REST door, because no MongoDB server was available.packages/rest/src/security-explain-envelope.test.tsstill builds its matcher and explain refusals by hand, with the old opening, and says neither@objectstack/formulanor@objectstack/plugin-securityis a dependency ofrest.plugin-securityis a devDependency now. The hand-built text is a fixture, not a pin of either producer, and the route reads only its code and status. Left as is.Generated by Claude Code