Skip to content

[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

Description

@os-zhuang

This is "A" of the maintainer ruling of 2026-08-12 on #7929 (comment 5266067250, verbatim 「接受你的全部建议。」 — adopting "B now, A next"). B landed in #8198 (merged a5dcb74, closing #7929 and #7988). This card is the sanctioned follow-up, not a new proposal.

⛔ Filed unassigned — claim it before writing code, per the repo's claim discipline.

Why A exists

B stopped driver-sql's cross-field INVALID_FILTER refusal from echoing its operands, for every caller, because the driver genuinely cannot tell an administrator's predicate from an author's. Measured on both merge boundaries: the read scope arrives as a bare FilterCondition in where, DriverQuery is Omit<QueryAST, 'object'> with no provenance slot, and the only thing that crosses (context) says who is asking, never which subtree they did not write.

The accepted cost, stated plainly in B's changeset: an author debugging their own cross-field filter now gets the redacted message too. Their diagnostic is relocated to the server log, not destroyed. A is what gives it back — behind a real mark rather than a guess.

What A has to build

  1. A provenance mark declared in packages/spec. Declared, not conventional — the whole point is that the driver may trust it because the contract defines it.
  2. Set at BOTH merge boundaries, or the fix is half-live:
    • plugin-security's CRUD injection — the ast.where = ast.where ? { $and: [ast.where, scoped] } : scoped merge around security-plugin.ts:2198;
    • service-analytics' read-scope merge (ObjectQLStrategy.withReadScope).
  3. driver-sql consumes it: a marked (policy-authored) subtree keeps B's redaction; an unmarked, author-written filter gets its full diagnostic back — both columns, the operator, the list index, the boundary reason.

⚠️ The invariant that must not regress — fail closed

An unmarked or ambiguous predicate must default to WITHHELD. The mark is permission to reveal, never a requirement to prove secrecy. A driver-side guess at provenance is precisely the shape triage rejected on #7929, and an inverted default would silently restore the original disclosure — including the sentence naming which column is the object's tenant-isolation column. Any design where "the mark is missing" lands on the disclosing branch is wrong, however convenient.

⚠️ One pin A is expected to REWRITE — it is not a regression

packages/runtime/src/cross-field-refusal-operand-withhold.test.ts contains a byte-equality assertion: an author-written $field filter and a policy-injected one must produce identical messages. That pin is the strongest available statement of "the driver cannot tell them apart" — which is exactly the fact A is chartered to change.

Whoever implements A must rewrite that assertion deliberately, and say so in the changeset. ⛔ Do not treat it as a failing test to make green by weakening it, and ⛔ do not delete it — replace it with its successor: marked and unmarked now differ, and the unmarked-by-default case still withholds. Left unexplained, this pin reads like a bug to the next agent who runs the suite.

Scope notes

Done means

An author's own cross-field filter names its columns again; a policy-injected one does not; an unmarked one does not; and all three are pinned at the two real merge boundaries with a real driver, not a mock.

Activity

  1. added theissue type on Aug 12, 2026
  2. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage: half-annotated shape completed ⇒ pm:queue, type Feature (maintainer-sanctioned A of the 2026-08-12 #7929 ruling — no new decision needed; the ruling and its scope are already on this card).

    Lane anchoring — one label, and it is domain:spec: the deliverable's contract half is a provenance mark declared in packages/spec, and any card touching packages/spec routes to the spec seat as sole owner. Removed the second lane label (domain:drivers) — one card, one lane; the driver/boundary consumers stay in card scope, not in the label.

    Cross-domain single-PR exception (designated here): the card is correctly argued as unsplittable — the mark set at only one merge boundary is half-live. The domain:spec seat (#6017) takes it under the exception path: the claim comment must declare the full file surface (packages/spec mark + plugin-security/src/security-plugin.ts ~2198 merge + service-analytics ObjectQLStrategy.withReadScope + drivers/driver-sql and driver-turso RemoteTransport + packages/runtime/src/cross-field-refusal-operand-withhold.test.ts rewrite) and run the targeted in-flight check against the identity / services / drivers lanes' claimed surfaces before dispatch.

    Model (not discretionary): expands the contract's public face ⇒ claude-fable-5 per the 2026-08-12 tiering clause. Size: L.

    Related state set this round: #8197 graded as evidence for this card's scope and parked pm:blocked on it — on landing, its unlock re-measure is part of proving A closed the class. The card's own fail-closed invariant (unmarked ⇒ withheld) and the #7865/#8116 prior-art check are dev must-verifies; the byte-equality pin rewrite must be explained in the changeset, per the card.


    Generated by Claude Code

  3. self-assigned this
    on Aug 13, 2026
  4. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Claim: PM loop round 2 (maintainer 2026-08-13: 「任务很多,并发加到3」)
    Session: session_01Euoy6wyfzgiWtgCg4s6JK2
    Branch: claude/issue-8220-read-scope-provenance-mark
    Worktree: objectstack-issue-8220
    Domain: domain:spec (cross-domain single-PR exception, designated by triage 2026-08-12 — full file surface declared per that path)
    File surface (FULL declaration, exception-path requirement): packages/spec (the provenance mark declaration — beside/related to the filter contract, NOT the #8342 injected-column module, which is prior art for style only), packages/plugins/plugin-security/src/security-plugin.ts (~:2198 CRUD-injection merge), packages/services/service-analytics (ObjectQLStrategy.withReadScope), packages/drivers/driver-sql, packages/drivers/driver-turso (RemoteTransport), packages/runtime/src/cross-field-refusal-operand-withhold.test.ts (deliberate pin REWRITE, explained in changeset), pins at both merge boundaries; changeset (stop on breach; explain in the report)
    Container & model: L, mode:cloud, model: claude-fable-5 (mandatory clause — expands the contract's public face; per the triage designation "Model (not discretionary)")
    Serial constraints cleared — targeted in-flight check run across the three affected lanes (exception-path requirement), read from each claim's declared surface:


    Generated by Claude Code

  5. added a commit that references this issue on Aug 13, 2026
  6. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Collection note (hot-handover provision per #8428; written by the dispatching seat session_01Euoy6wyfzgiWtgCg4s6JK2 so ANY successor can collect this card without that session):


    Generated by Claude Code

  7. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor
    {
      "issue": 8220,
      "status": "review_ready",
      "branch": "claude/issue-8220-read-scope-provenance-mark",
      "pr": null,
      "premise_still_valid": true,
      "summary": "A of the #7929 ruling is implemented end-to-end on the six-package surface the claim declared. DECLARED: packages/spec/src/data/filter-subtree-provenance.ts — FilterSubtreeProvenance ('author'|'policy'), markFilterSubtreeProvenance (non-enumerable Symbol.for key, first-mark-wins, silent no-op on frozen input), filterSubtreeProvenanceOf, and resolveFilterSubtreeProvenance(root, node) (positional, innermost-mark-wins, identity-located; unreachable/conflicting -> null). The mark crosses the driver boundary ON the where tree; DriverQuery grows no slot and its TSDoc declares the crossing and the fail direction. SET at both boundaries: plugin-security's RLS injection marks every injected scope (RLS/CBP/delegator/deny-sentinel) 'policy' and the caller's predicate 'author' only under the identity vouch opCtx.ast.where === opCtx.options.where (a sibling-rewritten tree is vouched for nobody); service-analytics' ObjectQLStrategy.withReadScope marks scope 'policy' and the strategy-built user filter 'author' (resolveFkAttr's scope arm 'policy', its internal idFilter deliberately unmarked). CONSUMED: driver-sql resolves withheld refusals against the query's own where root at its two existing seams (applyFilters catch for eager refusals; withWithheldFilterLog, now threaded with the root, for knex's lazy group callbacks) — 'author' gets a fresh INVALID_FILTER/400 carrying the full diagnostic (both columns, operator, list index, boundary reason); 'policy'/unmarked/unreachable/ambiguous keep #8198's redaction + server-log relocation. driver-turso's RemoteTransport applies the same resolution at buildWhereSQL entry under the same Symbol.for keys; driver-sqlite-wasm inherits and is pinned by the runtime suite. FAIL-CLOSED INVARIANT pinned at every layer incl. byte-equality unmarked==policy and a JSON-round-trip-drops-author-mark pin. PRIOR ART: #8116's injected-system-column-provenance was read first — it is COLUMN provenance (a derivation over metadata documents at rest: is storage behind an injected column), this is FILTER-SUBTREE provenance (an attestation about one query value in flight, knowable only at the merge moment); folding them would give the column verdict a mutable runtime carrier it must not have and the filter mark a document derivation it cannot have — separate mechanism justified in the module headers. TWO B-era pins rewritten deliberately, both explained in the changeset: (1) packages/runtime/src/cross-field-refusal-operand-withhold.test.ts byte-equality pin -> successor three-way split (policy withholds; vouched author names columns on BOTH real faces — CRUD and POST /analytics/query; unmarked withholds byte-identical to policy); (2) service-analytics cross-field-engine-fallback.test.ts pinned B's blanket redaction on the caller's OWN where — those callers are now the vouched author, so the routed cases assert the corpus diagnosticIncludes fragments back on the wire, with the policy-scope case gaining explicit non-disclosure assertions as the fail-closed pair. Changeset: minor spec + patch x4 consumers, non-breaking, adr-0087 gate green with no disposition needed.",
      "tests": "All green. New: spec filter-subtree-provenance.test.ts (15), driver-sql sql-driver-cross-field-provenance.test.ts (11, incl. the lazy group-callback disclosure seam), driver-turso remote-transport-cross-field-provenance.test.ts (7), plugin-security read-scope-provenance-mark.test.ts (4), service-analytics read-scope-provenance-mark.test.ts (5). Rewritten: runtime cross-field-refusal-operand-withhold.test.ts (11), service-analytics cross-field-engine-fallback.test.ts (93). Full suites: spec 10346, plugin-security 1036, driver-sql 1431(+52 skipped pre-existing), objectql 3496 (read-only dependency check), driver-sqlite-wasm 393, driver-turso 973, service-analytics 1675, runtime 2309. pnpm typecheck 127/127 (one transient SIGKILL-137 on plugin-hono-server at full concurrency, clean at --concurrency=4 and standalone). spec check:generated all 13 artifacts current (api-surface + export-origins regenerated for the new exports). dispatch-gates set run green: adr-anchors, changeset-gate-self-tests, adr-0087-registration, cross-package-test-inputs, doc-formula-expressions, docs-audit-scope, driver-conformance, durability-log-level, i18n, merge-driver, release-body, spec-parsed-alias, test-source-alias, type-source-resolution, changeset-fixed, dev-prereqs, query-options-erasure, type-check-coverage, nul-bytes, startup-registry-verdict. ESLint clean on every changed file.",
      "open_questions": [
        "BLOCKER (environment, not code): this dev session's egress proxy refuses ALL api.github.com calls with 'GitHub access is not enabled for this session. An org admin must connect the Claude GitHub App' (403 on read and write; no gh CLI in the image). Git push works, so the branch is the recovery index (tip f4210c60b061dfd78537a8f36c87e6f016256024), but I cannot open the draft PR or post this report as the #8220 comment myself. The PM is asked to open the draft PR from claude/issue-8220-read-scope-provenance-mark against main (first line 'Fixes #8220') and post this report verbatim.",
        "The author-restored tenant-arm message states which column is the tenant-isolation column TO THE VOUCHED AUTHOR (who typed that column name into their own filter). The card's 'Done means' grants the author 'the boundary reason', and the reason sentence names the column's role; if the maintainer wants the role sentence withheld even from a vouched author, it is a one-line change in uncompilableFieldReferenceError's authorText."
      ],
      "out_of_scope_findings": [
        "#8197 unlock (pm:blocked on this card): NOT absorbed. Its class — refusals OUTSIDE the cross-field family naming the target field — is behaviourally unchanged by A. What the re-measure should now show: read-scope-injected predicates arrive at the driver marked 'policy' from both boundaries, so those refusal families (e.g. INVALID_FIELD on an undeclared target, the $between arity refusal) have a real, spec-declared mark available to consume; closing #8197 becomes 'consume resolveFilterSubtreeProvenance in those builders' rather than 'invent provenance'. The blanket alternative (withhold the target field from every author) is now strictly dominated.",
        "plugin-sharing's read-path merge (sharing-plugin.ts composeAnd sites, ~:871/:896) is a THIRD read-scope merge boundary outside this card's file surface. Its injected filters arrive unmarked, which withholds — fail-closed, correct — but an author's filter on an object with active sharing rules loses the identity vouch at the security boundary (ast.where already rewritten) and stays redacted. Marking at plugin-sharing (policy on its filters; it could also preserve the vouch) would restore the author diagnostic in that composition; needs its own card in the sharing lane.",
        "The $not rewrite path (nullSafeNegationOperand) creates new nodes the resolver cannot find under the root, so an author's cross-field refusal nested under $not stays redacted (fail-closed; walk-raised refusals on the ORIGINAL nodes are unaffected). Documented at the site; a card only if a real author hits it.",
        "engine.resolveWhereTokens rewrites the tree when context tokens resolve, which breaks the identity vouch and (correctly) withholds; an author using {current_user_id} tokens in a cross-field filter keeps the redacted message. Noted here so the symptom has a written cause."
      ]
    }

    (Relayed verbatim by the dispatching PM session_01Euoy6wyfzgiWtgCg4s6JK2 — the dev's cloud session has no GitHub API egress; provenance and the environment blocker are recorded in the report's own open_questions. Draft PR opened by the PM: #8429.)


    Generated by Claude Code

  8. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    ACCEPT — reviewed by the dispatching seat (session session_01Euoy6wyfzgiWtgCg4s6JK2) against PR #8429's diff (17 files), not the report's self-description.

    Verified against the collection-note criteria: ① fail-closed invariant pinned at every layer (incl. the byte-equality unmarked==policy pin and the JSON-round-trip-drops-the-mark pin — the two ways the default could silently invert); ② the B-era byte-equality pin is REWRITTEN (+118/−27, three-way successor), not deleted or weakened, and the service-analytics engine-fallback pins flip WITH explicit non-disclosure assertions as the fail-closed pair; ③ mark declared in packages/spec (new module + DriverQuery TSDoc declaring the crossing), set at BOTH boundaries (security-plugin +33 with the identity-vouch condition — a sibling-rewritten tree vouched for nobody is exactly the right paranoia; objectql-strategy +20), consumed by driver-sql at its two real seams (+187, incl. the lazy knex group-callback seam) and turso's RemoteTransport, wasm inheriting; ④ the #8116 prior-art separation is argued in both directions (column provenance must not gain a mutable runtime carrier; filter-subtree provenance cannot have a document derivation) — accepted; ⑤ file surface exact to the exception-path declaration; ⑥ REST 5xx-withhold untouched; ⑦ done-means pinned with a real driver.

    For the maintainer, non-blocking: the report's open question 2 — the vouched author's restored tenant-arm message names the column's tenant-isolation ROLE (the card's "Done means" grants the author the boundary reason, so this is within the ruling; the author typed that column name themselves). If you want the role sentence withheld even from a vouched author, it is a one-line authorText change — say so and it rides the next spec-lane card.

    Out-of-scope findings: the dev's environment had no GitHub API (see the report), so the PM verified and filed on its behalf — the plugin-sharing third merge boundary is filed as a card (see cross-reference below); the $not-rewrite and resolveWhereTokens redaction notes stay documented-at-site per the dev's own grading (fail-closed, no author has hit them). #8197's unlock re-measure note is recorded in the report — it unblocks at this card's landing with a spec-declared mark to consume.

    Landing: draft until gate jobs conclude on the PR head; flip + auto-merge (squash) on the timed check; regen relay slot is free.


    Generated by Claude Code

  9. 3 remaining items

  10. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    Correction to a load-bearing sentence in this card's body — measured on #8197, recorded here because this is where the next reader will find it

    This card is closed and its work landed. One sentence in its body has since been measured false, and it is exactly the sentence a future author would rely on:

    #8197 is probably evidence for this card's scope, not independent work. […] A's mark closes that class without the blanket cost of withholding the target field from every author.

    That was written before A was implemented, and it assumes marked/unmarked maps onto policy/author. Measured on origin/main c8806ae0a against a real SqlDriver, running each predicate four ways (unmarked / policy-marked / author-marked / author-arm inside a merged $and):

    ⇒ The blanket author cost this card believed its mark avoided is still present — relocated, not removed. Consuming the mark at those sites with the fail-closed default intact does not merely withhold from policy; it strips the target-field name from every unmarked subtree.

    Nothing here impugns what this card built: A's mark works as specified at the boundaries that set it, and the fail-closed invariant is right. What is wrong is only the scope claim about #8197 — which is why #8197 is now needs-user-decision rather than a mechanical follow-up, with the disclosure trade-off escalated to the maintainer.

    Recorded rather than left standing, because a confident sentence in a merged card's body is indistinguishable from an established fact to whoever reads it next.


    Generated by Claude Code

  11. added a commit that references this issue on Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions