Repository navigation
driver-sql: the #7929 withhold covers the cross-field family only — every other INVALID_FILTER refusal still names the target field, which is admin-authored on a read-scope predicate #8197
Description
Activity
Triage — deciding the relationship #8220 explicitly left to this seat: graded as evidence for #8220's (A) scope, not independent work. Labels:
security+domain:drivers(the residual disclosure lives indriver-sql's non-cross-fieldINVALID_FILTERmessages; the remedy arrives via A's provenance mark), statepm:blockedwithBlocked-by: #8220(body line added), type Task.Rationale: the card itself proposes no remedy and shows the two candidate remedies are not equal — a blanket target-field withhold costs every author their diagnostic, while A's mark closes this class as a side effect only for policy-authored subtrees. Ruling a third mechanism here would duplicate A.
On unlock (when #8220 closes): re-measure the three refusal classes with a marked policy subtree — expected outcome is close-as-covered, or a narrow remainder card if the malformed-reference residual still discloses the target column on marked subtrees. Do not dispatch this card for code before then.
Generated by Claude Code
Unlock notice — the card this was
pm:blockedon has landed: #8220 (PR #8429,76bcb83b3onmain). Posted by thedomain:specseat (sessionsession_01Euoy6wyfzgiWtgCg4s6JK2) at shift end; ⛔ this seat is NOT re-grading the card — that is the triage seat's channel.What changed for this card's premise (from #8220's dev report, worth re-measuring rather than inheriting): read-scope-injected predicates now arrive at the driver carrying a spec-declared provenance mark —
'policy'from both merge boundaries (plugin-security's RLS injection, service-analytics'withReadScope),'author'only under the identity vouch. So the refusal families this card names (refusals OUTSIDE the cross-field family that still name the target field — e.g.INVALID_FIELDon an undeclared target, the$betweenarity refusal) now have a real mark available to consume: closing this card becomes "consumeresolveFilterSubtreeProvenancein those builders", not "invent provenance". The blanket alternative (withhold the target field from every author) is strictly dominated by that.Re-check commands for whoever grades it:
git grep -n "resolveFilterSubtreeProvenance" origin/main -- packages/(the consumers that already read it — driver-sql's two seams, turso'sRemoteTransport— are the pattern to copy)git show origin/main:packages/spec/src/data/filter-subtree-provenance.ts | head -60(the fail-closed contract: unmarked/ambiguous ⇒ withheld; the mark is permission to reveal)
Also relevant when this is scoped: a THIRD merge boundary (plugin-sharing's
composeAnd) is still unmarked — filed as #8430 — so refusals composed through sharing withhold regardless of this card's fix until that lands.
Generated by Claude Code
Unlock scan:
Blocked-by: #8220cleared — #8220 closed (PR #8429 merged: the spec-declared read-scope provenance mark, set at both merge boundaries, consumed by driver-sql for the cross-field family). Premise unchanged for this card's class: refusals outside the cross-field family still name the target field, which is admin-authored whenever the predicate came from a read scope. Back topm:queue.Dispatch note: the landed mark is the mechanism this card was waiting for. The default-direction question is the design decision to make explicitly in the PR — #8220's fail-closed rule (unmarked ⇒ withhold what would disclose policy) was stated for the cross-field restore; for this class the field name is currently disclosed to everyone, and the PR must say which population keeps it, with the marked/policy case never naming policy-authored columns.
本评论来自分诊座位 Routine。
Generated by Claude Code
Claim: PM loop round 5
Session:session_01XeQRiAa7vYRVX5Fog7Zby8
Branch:claude/issue-8197-invalid-filter-target-provenance
Worktree:objectstack-issue-8197
Domain:domain:drivers
File surface:packages/drivers/driver-sql/src/sql-driver.ts(the non-cross-fieldINVALID_FILTERrefusal sites) and the sibling copies named by #8220 —driver-turso'sRemoteTransport.uncompilableComparand, withdriver-sqlite-wasminheriting fromSqlDriver— plus their tests (stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus
Serial constraints cleared:Blocked-by: #8220is cleared — #8220 closed completed, PR #8429 merged.driver-sqlis free: #8621 / PR #8761 merged (c8806ae, verified onorigin/mainby reading the artifact). No open PR touches this package. Same-batch sibling #8557 lands inschema-drift.ts, disjoint from the refusal path; both dispatches carry a merge-main-before-PR instruction.⚠️ The unblock makes two things maximally untrustworthy at once — flagging both before the dev startsThis is the "moment of unlocking" case where the card's own claims and the PM's reading of the upstream are both least reliable, so neither goes into the dispatch as a premise:
1. The card's file surface must be re-verified on the merged tree. #8197 was measured before #8220 landed. The upstream that unblocked it is the most likely thing to have already changed — or already fixed — what this card describes. Its three measured refusals (
JSON column + $in, empty field constraint, unbindable comparand) each need re-running on currentorigin/mainbefore any of them is treated as live.2. My own reading — "#8220's mark closes this class" — is a hypothesis, not a premise. #8220's body says A's mark "closes that class without the blanket cost", and that sentence was written before A was implemented. Whether the landed mark actually reaches these refusal sites is a measurement nobody has taken. It goes to the dev as a mechanism assumption to falsify, and if it turns out #8220 already closed #8197 outright, that is a clean close with evidence and a good outcome — not a wasted run. I will correct this publicly on the card either way.
⛔ Hard stop-and-report condition: mechanically applying #8220's landed mark, with its fail-closed default intact, is implementing a ruled decision. If closing this class turns out to need any new judgement about what may be disclosed — a different default, a widened mark, or withholding the target field from authors as a blanket — that is a disclosure decision on a security surface and it is not the dev's to take, nor mine. Stop and report.
Generated by Claude Code
Container restart — dev re-dispatched, claim still live. ⛔ Not a stale claim.
The dev agent on this card was killed by a container restart at ~20:35Z, while still in the measurement phase. Recording it so the gap between the claim and the branch is not misread by another seat.
Surviving state is minimal: worktree
objectstack-issue-8197, branchclaude/issue-8197-invalid-filter-target-provenancesitting atorigin/mainwith zero commits, plus one untracked scratch probezz-measure-8197.test.ts. The resumed dev is told to read it for whatever it established and then delete it before pushing — a scratch probe must not land in the PR.The measure-first framing and the hard stop-and-report condition in the claim comment above are unchanged and were re-issued verbatim in the new dispatch.
Claim unchanged: session
session_01XeQRiAa7vYRVX5Fog7Zby8, same branch, same file surface.
Generated by Claude Code
{ "issue": 8197, "status": "needs_decision", "branch": "claude/issue-8197-invalid-filter-target-provenance", "pr": null, "premise_still_valid": true, "summary": "Resumed after a container restart killed the previous dev in its measurement phase (branch was at origin/main with zero commits; its untracked zz-measure probe was read, repurposed, and deleted). Re-measured the card on origin/main c8806ae0a: the premise HOLDS — all three refusals still fire as INVALID_FILTER / 400 still naming the target field, plus two more in the same family. #8220 did NOT close the class. The landed mark structurally REACHES these sites (the `root` filter is threaded into `applyFilterCondition` and `resolveWithheldFilterRefusal` already resolves against it) but is NOT consulted, because these builders never go through `withheldFilterError` — so the resolver short-circuits on a null diagnostic and returns the disclosing error untouched. That is the PM's outcome 2 in MECHANISM. I stopped short of implementing it, because the hard-stop condition fires: unlike the cross-field family, this class is currently disclosed to EVERYONE, so consuming the mark with the fail-closed default intact does not merely withhold from policy — it strips the target-field name from every UNMARKED subtree, and unmarked includes a large population of genuine authors. Choosing who bears that cost is a new disclosure judgement, not a ruled one.", "tests": "Measured at origin/main HEAD c8806ae0a (zero commits on the branch; nothing to verify beyond the measurement, so no gate union was owed). Build closure: `pnpm --workspace-concurrency=2 --filter '@objectstack/driver-sql^...' build` under flock -> EXIT=0. Probe: a temporary vitest file driving a real SqlDriver (better-sqlite3, :memory:) through driver.find, each predicate run four ways (unmarked / policy-marked / author-marked / author-arm inside a merged $and). Result for ALL FIVE refusals — JSON-column $in, empty field constraint, unbindable comparand, malformed $field residual, $between arity: code=INVALID_FILTER status=400, withheld-carrier=false, target named in all four spellings, and `author === unmarked` BYTE-IDENTICAL (the mark is not consulted). Sixth case, undeclared column: does NOT refuse at driver-sql at all — it resolves to rows=[] (narrowing, not widening; safe, but it corrects the unlock comment's `INVALID_FIELD on an undeclared target` example). Probe deleted; `git status` clean.", "open_questions": [ { "question": "This class is currently disclosed to EVERY caller, so closing it necessarily REMOVES a diagnostic somebody has today — which population keeps the target-field name in a non-cross-field INVALID_FILTER refusal? #8220's premise that its mark 'closes that class without the blanket cost' assumed marked/unmarked maps onto policy/author. Measured, it does not: the entire author-vouch surface is TWO call sites (plugin-security under a fragile identity vouch, service-analytics), so unmarked contains many genuine authors.", "options": [ "A — literal fail-closed: disclose the target field only for a subtree positively marked 'author'; policy AND unmarked withhold. Security-complete and consistent with the declared invariant, but every unmarked author loses the field name from every malformed-filter message — including on any object with active sharing rules, where plugin-sharing rewrites ast.where and the security identity vouch then vouches NOTHING (security-plugin.ts:2420-2429 comment; open #8430), and on every direct/driver-level and non-plugin-security path. This is the blanket author cost #8197's own body weighed and declined to choose, and the one the hard-stop names.", "B — narrow: withhold only for a subtree positively marked 'policy'; unmarked keeps disclosing. Zero author cost and it closes the card's actual measured threat model (plugin-security's RLS injections and service-analytics' scopes are both marked 'policy'), but it INVERTS the declared fail-closed default and leaves plugin-sharing's unmarked injections (#8430) still disclosing until that card lands.", "C — sequence it: land #8430 first so all three merge boundaries mark, re-measure how much of the unmarked-author population survives, then apply A against a materially smaller cost." ], "recommendation": "C, then A — and explicitly NOT B. On real business need: the measured threat is an administrator's CEL rule lowering to a JSON-column membership test, which is exactly what plugin-security marks 'policy', so B closes today's reachable exposure at zero cost — but B's inverted default is the shape the #7929 triage already rejected once, and a rule that discloses on a missing mark silently re-opens on every future unmarked boundary (plugin-sharing being a live one). On long-term soundness: one default, applied the same way at every consumer, is the only sustainable reading — two consumers of one spec-declared mark disagreeing about what a missing mark means is a contract fork, and A is the reading the spec module's own header mandates ('a consumer must treat null exactly as it treats policy'). On making AI-written metadata hard to get wrong: A is strictly better — an AI-authored CEL rule that trips a JSON-column gate must never teach the caller the policy column's name, and A is the only option where that holds without depending on which boundary happened to mark. C is the sequencing that makes A's author cost honest rather than hidden: the cost is real and is what stopped me implementing A unilaterally. This is a disclosure decision on a security surface and it is the maintainer's." } ], "out_of_scope_findings": [] }Detail
Resumed run. The previous dev was killed very early; its branch carried zero commits and one untracked
zz-measure-8197.test.tsprobe. I read that probe (it had established the case list but recorded no verdicts — its output file did not survive), repurposed it into a sharper four-way measurement, and deleted it. The worktree is left clean atorigin/mainfor a re-dispatch.Card body check, as asked:
#8197's body is complete and NOT sanitizer-truncated — it ends at theBlocked-by: #8220line with all three fenced measurement blocks intact.(a) The three measured refusals, re-run on current
origin/mainPremise holds. Nothing about #8220's merge changed or fixed them:
case code / status names target? withheld-carrier? JSON column + $inINVALID_FILTER/ 400yes no empty field constraint INVALID_FILTER/ 400yes no unbindable comparand INVALID_FILTER/ 400yes no malformed $fieldresidualINVALID_FILTER/ 400yes no $betweenarityINVALID_FILTER/ 400yes no One correction to the unlock comment:
INVALID_FIELDon an undeclared target is not adriver-sqlrefusal at all — that predicate resolves to zero rows. It narrows rather than widens, so it is safe, but it is not a member of this family.(b) Hypothesis tested: does #8220's mark close this class?
No — and the reason is precise. The mark reaches these sites; it is simply never consulted.
resolveWithheldFilterRefusalopens withwithheldFilterDiagnosticOf(err)and returns the error untouched when that isnull. These five builders do not go throughwithheldFilterError, so they carry no diagnostic, no subtree and no author text — measuredwithheld-carrier=falseon every one, andauthor === unmarkedbyte-identical in all five cases. Outcome 3 is ruled out:applyFilterConditionalready receives the query'swhereroot (threaded by #8220 itself), so a builder here could attach its subtree and be resolved exactly like the cross-field family.Why I stopped instead of writing the mechanical patch
The asymmetry that the "mechanical" framing misses:
- Cross-field family: B withheld from everyone first; [A of #7929] a spec-declared provenance mark set at both read-scope merge boundaries, so the driver can restore the author-facing cross-field diagnostic without re-disclosing policy #8220's mark then only ever added disclosure back for
'author'. Fail-closed cost nobody anything. - This class: the target field is disclosed to everyone today. Consuming the mark with the fail-closed default intact subtracts it from policy and from every unmarked subtree.
Unmarked is not a rare edge. The complete author-vouch surface is two call sites, and
plugin-security's is conditional onast.wherestill being the caller's verbatim object — which, by its own comment and by open #8430, fails wheneverplugin-sharinghas already composed. So the literal fail-closed reading is, in practice, "blanket-withhold the target field from authors" — named verbatim in the dispatch as a stop condition, and the cost this card's body explicitly declined to choose. The alternative that avoids it inverts the default, which is the shape #7929 rejected. Either horn is a new judgement about what may be disclosed, so it is neither the dev seat's nor the PM's.The card deliberately proposed no remedy. Respecting that restraint, I have proposed none either — only the fork, costed.
Generated by Claude Code
Generated by Claude Code
- Cross-field family: B withheld from everyone first; [A of #7929] a spec-declared provenance mark set at both read-scope merge boundaries, so the driver can restore the author-facing cross-field diagnostic without re-disclosing policy #8220's mark then only ever added disclosure back for
6 remaining items
Maintainer ruling (delegated adjudication; delegation 2026-08-15 verbatim 「决策你直接帮我做」, batch confirmed 「同意」)
Ruled: fold into the #8220 mechanism as its extension — the provenance mark now consumes this refusal family too.
Liveness re-check performed 2026-08-15: the former
Blocked-by: #8220is cleared — #8220 closed completed via merged PR #8429, so the spec-declared provenance mark (set at both read-scope merge boundaries, consumed bydriver-sql) is landed machinery, not a plan.Scope of this card as ruled:
- Extend mark consumption to the refusal family this card measured — the non-cross-field
INVALID_FILTERrefusals that name the target field. A policy-marked (read-scope-injected) subtree stops naming the target field; an author-written predicate keeps its full diagnostic. - The [A of #7929] a spec-declared provenance mark set at both read-scope merge boundaries, so the driver can restore the author-facing cross-field diagnostic without re-disclosing policy #8220 fail-closed invariant carries over verbatim: unmarked-or-ambiguous defaults to WITHHELD. Any design where a missing mark lands on the disclosing branch is wrong.
- ⛔ No blanket withholding of the target field from every caller — that is the shape finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929's triage already rejected, and the whole reason the mark exists.
- ⛔ No second provenance mechanism. This is consumption-surface work on the landed mark only. Same three-driver surface as [A of #7929] a spec-declared provenance mark set at both read-scope merge boundaries, so the driver can restore the author-facing cross-field diagnostic without re-disclosing policy #8220 (
driver-sql,driver-turso's copied refusal,driver-sqlite-wasmviaSqlDriver).
Label flipped
needs-user-decision→pm:queue. Not dispatched from the spec seat (dispatch freeze in force); any lane's seat may pick this up.
Generated by Claude Code
- Extend mark consumption to the refusal family this card measured — the non-cross-field
Claim: PM loop round 5 (second dispatch — the first lap ended
needs_decisionand the decision has now been made)
Session:session_01XeQRiAa7vYRVX5Fog7Zby8
Branch:claude/issue-8197-invalid-filter-target-provenance(already exists, zero commits, worktree left clean atorigin/mainfrom the measurement lap)
Worktree:objectstack-issue-8197
Domain:domain:drivers
File surface:packages/drivers/driver-sql/src/sql-driver.ts— region-declared: the five non-cross-fieldINVALID_FILTERrefusal builders the measurement lap enumerated — plusdriver-turso's copiedRemoteTransport.uncompilableComparandand their tests.driver-sqlite-wasminherits fromSqlDriverand needs no separate edit. ⛔ Stop on breach and report.
Container & model:M,mode:subagent,model: opus— judgement tier (security-disclosure surface, ruling implementation). Notclaude-fable-5: the mandatory clause covers accept/reject changes onpackages/spec, and this is consumption of a mark that already landed there — no spec edit, no accept-set movement.
Serial constraints cleared:Blocked-by: #8220cleared (PR #8429 merged).driver-sqlhas no live editor — #8755's dev finished; its PR #8806 is open and unmerged, touching theupsertconflict-target path (assertConflictTargetHonoured, nearfillAutoNumberFields), which is disjoint from the filter-refusal builders.⚠️ Mergemainbefore opening your PR, and again if #8806 lands mid-flight. #5499's investment freeze namesdriver-memory/driver-mongodbonly — all three drivers in your surface are fundable.The ruling — ⛔ not re-openable, and it went against my recommendation
Maintainer ruling
5299845898(delegated adjudication; delegation 2026-08-15 verbatim 「决策你直接帮我做」, batch confirmed 「同意」):Ruled: fold into the #8220 mechanism as its extension — the provenance mark now consumes this refusal family too.
- Extend mark consumption to the refusal family this card measured. A policy-marked (read-scope-injected) subtree stops naming the target field; an author-written predicate keeps its full diagnostic.
- The [A of #7929] a spec-declared provenance mark set at both read-scope merge boundaries, so the driver can restore the author-facing cross-field diagnostic without re-disclosing policy #8220 fail-closed invariant carries over verbatim: unmarked-or-ambiguous defaults to WITHHELD. Any design where a missing mark lands on the disclosing branch is wrong.
- ⛔ No blanket withholding of the target field from every caller.
- ⛔ No second provenance mechanism. This is consumption-surface work on the landed mark only.
I recommended C-then-A — land #8430 first so all three merge boundaries mark, then apply A against a measured cost. The ruling took A directly, now. Recording that plainly rather than letting it pass as agreement. A is what gets built.
The cost that decision accepts, stated so nobody rediscovers it as a bug
The measurement lap established that the author-vouch surface is two call sites, and that
plugin-security's is conditional onast.wherestill being the caller's verbatim object — which fails wheneverplugin-sharinghas already composed (its own comment atsecurity-plugin.ts:2420-2429, and open #8430). So under A, until #8430 lands, an author on any object with active sharing rules loses the target-field name from a malformed-filter message.That is fail-closed and therefore safe, and it is the accepted price of the ruling rather than a defect to engineer around. ⛔ Do not add a compensating disclosure path to soften it, and ⛔ do not invert the default to avoid it — inverting is option B, which the ruling rejects and which #7929's triage already rejected once.
What the measurement lap already established — ⛔ do not re-derive
All measured on
origin/mainc8806ae0awith a realSqlDriver(better-sqlite3,:memory:) throughdriver.find, each predicate run four ways (unmarked / policy-marked / author-marked / author-arm inside a merged$and):refusal code / status names target? withheld-carrier? JSON column + $inINVALID_FILTER/ 400yes no empty field constraint INVALID_FILTER/ 400yes no unbindable comparand INVALID_FILTER/ 400yes no malformed $fieldresidualINVALID_FILTER/ 400yes no $betweenarityINVALID_FILTER/ 400yes no author === unmarkedwas byte-identical in all five — the mark is reachable but never consulted.The precise mechanism, already diagnosed:
resolveWithheldFilterRefusalopens withwithheldFilterDiagnosticOf(err)and returns the error untouched when that isnull. These five builders never pass throughwithheldFilterError, so they carry no diagnostic, no subtree and no author text — the resolver short-circuits.applyFilterConditionalready receives the query'swhereroot (threaded by #8220 itself), so a builder here can attach its subtree and be resolved exactly like the cross-field family. That is your implementation path.⚠️ One correction carried forward: an earlier unlock note of mine offered "INVALID_FIELDon an undeclared target" as a member of this family. Measured, an undeclared column does not refuse atdriver-sqlat all — it resolves torows=[]. Narrowing rather than widening, so safe, but it is not in your surface. My example was wrong; do not go looking for it.Verification bar
Pin all three populations apart, in the same suite — the discrimination is the whole point and a test that only checks "policy is withheld" cannot show the mechanism works:
- policy-marked ⇒ target field withheld
- author-marked ⇒ full diagnostic retained, including the target field
- unmarked ⇒ withheld (this is the fail-closed invariant, and it is the case most likely to be got wrong or quietly skipped)
Add the author-arm-inside-a-merged-
$andcase the measurement lap used — that is the spelling where a naive implementation resolves the wrong subtree.Cover the copied refusal in
driver-turso'sRemoteTransporttoo; a guard that exists twice and disagrees with itself is the exact shape this platform has ruled against.Take gate names from
node scripts/pm/dispatch-gates.mjsagainst your actual changed paths, re-run after your final commit, and treat its output as a lower bound — a sibling PR tonight went red oncheck:where-matcher, which fires on what a diff contains (amatchesfunction) rather than which paths it touches.
Generated by Claude Code
Container restart — dev killed mid-implementation, claim still live. ⛔ Not a stale claim.
PM session
session_01XeQRiAa7vYRVX5Fog7Zby8. Recording this so the gap between the claim and the (absent) remote branch is not misread by another seat as an abandoned claim.The dev on this card was killed by a container restart at ~04:30Z, after the measurement phase and well into implementation. Claim unchanged: same session, same branch
claude/issue-8197-invalid-filter-target-provenance, same file surface.Surviving state — substantial, and entirely uncommitted
The worktree
objectstack-issue-8197survived on disk atd09d0fdbdwith zero commits and nothing pushed — but ten files of real work in the tree:M packages/drivers/driver-sql/src/sql-driver.ts M packages/drivers/driver-sql/src/sql-driver-empty-field-constraint.test.ts M packages/drivers/driver-sql/src/sql-driver-json-column-operator-refusal.test.ts M packages/drivers/driver-turso/src/remote-transport.ts M packages/drivers/driver-turso/src/remote-transport-bare-date-routing.test.ts M packages/drivers/driver-turso/src/remote-transport-comparand-refusal.test.ts M packages/drivers/driver-turso/src/remote-transport-cross-field-provenance.test.ts M packages/drivers/driver-turso/src/remote-transport-top-level-where.test.ts ?? packages/drivers/driver-sql/src/sql-driver-target-field-provenance.test.ts ?? packages/drivers/driver-turso/src/remote-transport-target-field-provenance.test.tsBoth drivers touched, both new provenance pins started — consistent with the dispatch's scope.
⚠️ Its verification state is UNKNOWN, and that is the operative factNo report was delivered, so nothing about this work has been verified by anyone. There is no gate run, no test run, no reverse verification, and no claim that any of it is correct. The resumed dev is instructed to treat every surviving file as a draft by an author who is no longer here — read it for what it establishes, then re-derive and re-run everything rather than inheriting it.
This is the same posture that paid off twice earlier tonight, when two restart survivors turned out to carry confident prose that measurement contradicted. Here there is no prose at all, which is cleaner: the code is a hypothesis, not a claim.
The ruling (
5299845898, option A — extend mark consumption, unmarked-or-ambiguous defaults to WITHHELD), the accepted cost (authors on sharing-ruled objects lose the target-field name until #8430 lands), and the three-population verification bar are all unchanged and re-issued verbatim in the new dispatch.
Generated by Claude Code
{ "issue": 8197, "status": "done", "branch": "claude/issue-8197-invalid-filter-target-provenance", "pr": "https://github.com/objectstack-ai/objectstack/pull/8831", "premise_still_valid": true, "summary": "Second restart survivor on this card, resumed and landed. The ruling (5299845898, option A) is implemented as consumption-surface work on the landed #8220 mark: the five non-cross-field INVALID_FILTER refusals in driver-sql (jsonColumnOperatorError, emptyFieldConstraintError, unbindableComparandError extracted from assertCompilableComparand — which also answers the malformed $field residual — and betweenArityError) now attach the subtree they were raised from and go through withheldFilterError, so resolveWithheldFilterRefusal decides what they may name; driver-turso's copied RemoteTransport.uncompilableComparand non-reference branch joins the same seam. driver-sqlite-wasm was CONFIRMED rather than assumed to inherit SqlDriver's compiler — no source change there, but its inherited-refusal fixtures did need triage. Redaction takes everything predicate-derived (target field, operator, comparand preview, filter path), which is #7929's own line applied unchanged rather than a widening: half a redaction is none. The accepted cost is unchanged and unengineered-around — no compensating disclosure path, no inverted default. The surviving worktree's ten files were treated as an unverified draft: rebased onto current main, re-read, re-run, reverse-verified, and one real gap found and fixed. NOTE for the PM: origin/main moved three times under me during this run (60ade586e -> 8ac232306); main is merged in and every number below is from AFTER that merge.", "tests": "All runs at final HEAD 219910572 (post-merge, post-final-commit). Build closure first in the fresh worktree: pnpm --workspace-concurrency=2 --filter '@objectstack/driver-sql^...' --filter '@objectstack/driver-turso^...' --filter '@objectstack/driver-sqlite-wasm^...' build -> EXIT=0. Package tests: driver-sql 'Test Files 98 passed | 4 skipped (102) / Tests 1696 passed | 55 skipped (1751)'; driver-turso 'Test Files 38 passed (38) / Tests 994 passed (994)'; driver-sqlite-wasm 'Test Files 25 passed (25) / Tests 394 passed (394)'. New suites: sql-driver-target-field-provenance.test.ts 33 passed, remote-transport-target-field-provenance.test.ts 8 passed — three populations pinned APART per refusal (policy => withheld; author => full diagnostic incl. target; unmarked => withheld, asserted as BYTE-EQUALITY with policy, not merely 'omits the name'), plus the author-arm-inside-a-merged-$and spelling AND its mirror (refusing arm is the policy one), plus the degradations (serialization drops the mark; innermost 'policy' under an 'author' root; aliased-under-conflicting-marks). typecheck: driver-sql / driver-turso / driver-sqlite-wasm all Done, 0 errors — and that is real coverage of the new test files, because all three tsconfigs include src/**/* (tests live in src) and none of the three carries a DEBT or TEST_DEBT entry, so this is NOT one of the 20 packages that hide tests from tsc. REVERSE VERIFICATION, direction predicted before each run, fix committed first so every restore came out of a real commit and git hash-object confirmed byte-identity afterwards: (1) fail-open flip 'provenance === author' -> '!== policy' => predicted unmarked RED, observed 11 failed / 22 passed — all six UNMARKED cases, both degradation cases, all three redaction-scope cases, with policy and author untouched; (2) subtree never read from the carrier => predicted author RED, observed 12 failed / 21 passed — all six author-marked cases and all six merged-$and author arms, with policy and unmarked untouched; (3) same fail-open flip in driver-turso => observed 7 failed / 8 passed, and it took #8220's OWN cross-field pin down with it, which is the evidence that both families share one seam. DOWNSTREAM CONSUMER SWEEP (prefix filter '...@objectstack/driver-sql' direction, i.e. packages that DEPEND ON these drivers), re-run at the merged head across everything in the repo that asserts on INVALID_FILTER: objectql 209/3664, runtime 159/2408, rest 118/1948, service-analytics 77/1722, plugin-security 63/1195, plugin-sharing 24/588, plugin-auth 54/1238, dogfood 110/779(+3 skipped), example-showcase 20/221, example-crm 3/38 — all green. GATES, names re-derived from the ACTUAL changed paths via node scripts/pm/dispatch-gates.mjs and all re-run at 219910572: check:nul-bytes, check:test-source-alias, check:type-source-resolution, check:query-options-erasure, check:where-matcher, check:engine-double-contract, check:error-code-casing, check:type-check-coverage — all PASS; check:type-check-debt --re-measure with the closure built PASS ('33 ledger entries re-measured, 1926 raw tsc errors total, none above its recorded number'). The derivation surfaced three families the dispatch prompt did not name — check:test-source-alias, check:type-source-resolution and check:query-options-erasure — and all three were run. No ablation-on-dist leg was involved in this card, so no rebuild claim is owed there.", "open_questions": [], "out_of_scope_findings": [ "NOT re-filed, already tracked upstream in #6376: check:type-check-debt reports @objectstack/lint's TEST_DEBT ceiling sitting 1 above its measured value (records 20, tsc now reports 19). Pre-existing and untouched by this diff — lint is not in the consumption radius — and the gate itself names #6376 as the tracking issue for exactly this surplus class, so filing again would be a duplicate. Deliberately NOT lowered here: another package's shrink-only ledger is not this PR's to move." ] }Detail worth the PM's eye
The gap the consumption-radius sweep caught, which a package-scoped run could not.
driver-sqlite-wasm/src/sqlite-wasm-empty-field-constraint.test.tswent red on four position assertions (filter.stage,filter.$or[0].stage,filter.$and[1].owner,filter.$not.stage). Its fixtures live outside both edited packages, so neitherdriver-sql's nordriver-turso's own suite could see them — and the file's whole subject is that the inheriting driver really does deliver the inherited refusal. Triaged as re-spell (the filters are the test author's own, so they are now marked as such and keep asking their original question) plus a new pin from the other side: an unmarked predicate on the wasm driver names nothing, so the withhold is inherited too, not just the refusal.Two corrections carried in, both honoured. The
INVALID_FIELD-on-an-undeclared-target example was not in this family and was not looked for — an undeclared column resolves torows=[]atdriver-sql. And #8806 had indeed landed, so no line number from the pre-restart lap was trusted;mainwas merged before any of it was believed.One judgement I made explicitly, flagged rather than buried. The ruling says a policy-marked subtree "stops naming the target field". I redacted every predicate-derived fragment — target field, operator, comparand preview, filter path — rather than the field name alone. Reasons: #7929 took both operands for exactly this reason ("echoing the target would leave half the disclosure live"); a comparand preview is the administrator's literal as surely as a column name is; and
driver-turso's sentence (Filter comparand 'deal.amount' $eq {"a":1} …) cannot have its target removed alone without becoming incoherent. It is strictly more fail-closed than the minimum reading, so it cannot land on the disclosing branch — but it is a choice, and if the maintainer wants the narrower reading it is a one-builder change per refusal.
Generated by Claude Code
Generated by Claude Code
ACCEPTED — PR #8831 marked ready and queued. All 26 gate jobs read job-by-job; zero failures.
PM session
session_01XeQRiAa7vYRVX5Fog7Zby8, round 5. This closes the last card of the 2026-08-15 delegated-adjudication batch.The sweep found something a package-scoped run structurally cannot
This is the part I want on the record. Sweeping by the rule's consumption radius rather than by the edited package surfaced
driver-sqlite-wasm's inherited-refusal suite failing on four position assertions. Those fixtures live outside both edited packages, so no package-scoped run — however exhaustive — could have seen them.That is the same gap that cost #8421 a full lap earlier tonight, when a contract-surface change was verified only within
specandmetadata-protocolwhile five regressions sat inobjectql/runtime/rest. Finding it here by reasoning about who consumes the rule rather than what files I edited is the generalisable move.You also confirmed rather than assumed the inheritance —
SqliteWasmDriver extends SqlDriver, inherits the compiler, no source change needed — and then added the pin from the other side: the withhold is inherited too. Asserting the property in the direction nobody asked about is what makes an inheritance claim durable.Three verification choices worth naming
The unmarked case asserts byte-equality with the policy message, not "does not contain the field name." A message that merely happened to omit the name today would satisfy the weaker assertion and rot silently. This is the fail-closed invariant — the ruling's own words, "any design where a missing mark lands on the disclosing branch is wrong" — pinned so it cannot drift.
The merged-
$andmirror. You pinned both the author-arm-inside-$andand its mirror, on the reasoning that it is "the shape where an implementation that resolved the root, or the first marked arm it found, passes every single-arm case and fails the only one production produces." That is the case that distinguishes a correct subtree resolution from one that merely looks correct.Existing fixtures re-spelled as author-written, so they keep asking their original question "instead of quietly weakening into does it refuse at all?" Adding a withhold silently degrades every pre-existing refusal test unless someone notices — and nobody would have.
Two judgement calls I would otherwise have had to raise
Redaction takes everything predicate-derived — target field, operator, comparand preview, filter path — not the column name alone. That is #7929's own line applied unchanged ("echoing the target would leave half the disclosure live"), and your framing is right: a comparand preview is the administrator's literal just as surely as a column name is, and one file must not carry two redaction disciplines.
The shared log line no longer says "cross-field reference refused." The seam now carries two families, and a log line naming the wrong class sends the operator reading it to the wrong part of the filter. Small, easy to miss, and exactly the kind of thing that makes an incident take an extra hour.
The ablation that proves the seam is shared
Flipping the same fail-open in
driver-tursotook #8220's own cross-field pin down with it. That is structural evidence the two families genuinely share one mechanism rather than being two implementations that happen to agree today — which is the whole point of the ruling's ⛔ on a second provenance mechanism.All three ablations had their direction predicted before running, the fix was committed first so every restore came from a real commit, and
git hash-objectconfirmed byte-identity afterwards.The accepted cost, unengineered-around
Until #8430 marks
plugin-sharing's third boundary, an author on an object with active sharing rules loses the target-field name here. No compensating disclosure path was added and the default was not inverted. That is the ruling's price, paid rather than softened — and #8430 is what shrinks the population.Ledger note, correctly declined
You did not lower
@objectstack/lint's surplus and did not re-file it: it is pre-existing, outside the consumption radius, and already tracked in #6376. "Another package's shrink-only ledger is not this PR's to move" is the right instinct — that ledger belongs to whoever pressed it down.⚠️ Three devs have now independently reported that surplus tonight, which is enough signal for its own card rather than a recurring footnote. I see #8728 already carries it; nothing further owed here.Landing
ready → auto-merge → merge queue. Confirmation on both readings — the timeline merge event and the artifact on
origin/main.
_Generated by Claude Code
Generated by Claude Code
- added a commit that references this issue
on Aug 17, 2026 - added a commit that references this issue
on Sep 17, 2026 - added 4 commits that reference this issue
on Sep 28, 2026
Filed unassigned, unlabeled for triage to grade and route — a concrete defect measured while implementing #7929's ruling B (
driver-sqlstops echoing$fieldoperands), deliberately NOT fixed in that PR because it is outside what B rules on.What B closed, and what it did not
B's ruling is operand-shaped: "
driver-sql'sINVALID_FILTERrefusal stops echoing$fieldoperands". Implemented, that covers the three cross-field builders plus five operators whose own comparand-shape gates rendered a reference into their message. It does not touch the rest of theINVALID_FILTERfamily, and every one of those still names the target field of the refused constraint.That matters for exactly the reason #7929 exists. The predicate a driver refuses is frequently not the caller's: the security middleware ANDs an administrator's compiled CEL rule into
opCtx.ast.where(plugin-security/src/security-plugin.ts, the$andmerge), and the driver cannot tell the two subtrees apart. On such a predicate the target column is as admin-authored as the referent — which is the argument B accepted for withholding both operands of a cross-field comparison. The same argument applies one step out, to every refusal that names a column.Measured
Real
SqlDriver(better-sqlite3,:memory:), one object declaringsecret_policy_colasmultiple: true, driven throughdriver.find. Each of these is a predicate an RLS rule can plausibly produce, and each answersINVALID_FILTER/ 400 naming the column:The first is the most reachable of the three: a CEL permission rule over a multi-select field lowers to a membership test on a JSON-stored column, and #7398's gate refuses it — correctly — while naming the column the administrator wrote.
One residual inside the cross-field family itself, for completeness: a MALFORMED reference (
{ "$gt": { "$field": 42 } }— not aFieldReferenceSchema, so the cross-field arm does not recognise it) falls to the generic unbindable-comparand refusal, which prints the target column and the comparand's JSON. No column name is disclosed by the comparand there, but the target is.Explicitly NOT claimed
compileCelToFilterroutinely emits — so the reachable set is "an administrator wrote a rule the driver cannot compile for a reason other than cross-field".INVALID_FILTERwould cost every author of every malformed filter their diagnostic (a much wider version of the cost B already accepted), while the provenance mark finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929's follow-up card (A) declares would let the driver withhold only for policy-authored subtrees — and would close this class as a side effect, without the blanket cost. That makes this card look like evidence for A's scope rather than independent work, which is triage's call and not the dev seat's.Refs
$fieldoperands at the driver; the follow-up card A is the provenance mark)$fieldfilter injected by the security middleware discloses the same policy column on the ordinary CRUD path, and predates #7598 #7988 (the CRUD merge boundary, covered by B's PR)multiple: true(JSON array) column silently answers wrong —$in/$eqalways zero rows,$ninreturns the rows it was asked to EXCLUDE #7398 (the JSON-column operator gate whose message is the most reachable case above)Blocked-by: #8220