|
| 1 | +--- |
| 2 | +"@objectstack/spec": minor |
| 3 | +"@objectstack/driver-sql": patch |
| 4 | +"@objectstack/driver-turso": patch |
| 5 | +"@objectstack/plugin-security": patch |
| 6 | +"@objectstack/service-analytics": patch |
| 7 | +--- |
| 8 | + |
| 9 | +feat(spec): filter-subtree provenance — the cross-field refusal names an author's own columns again, without re-disclosing policy (#8220, A of the #7929 ruling) |
| 10 | + |
| 11 | +#8198 (B of the 2026-08-12 #7929 ruling) made the SQL family's cross-field |
| 12 | +`{ $field }` refusal withhold its operands from **every** caller, because the |
| 13 | +predicate reached the driver as a bare `FilterCondition`: an administrator's |
| 14 | +CEL sharing/permission rule and the author's own filter were indistinguishable |
| 15 | +there. The accepted, named cost was the author's diagnostic. This change is A |
| 16 | +— the sanctioned follow-up that pays it back behind a real mark instead of a |
| 17 | +guess. |
| 18 | + |
| 19 | +**The mark** (`@objectstack/spec/data`, `filter-subtree-provenance.ts`) is a |
| 20 | +spec-declared symbol on a filter subtree: `markFilterSubtreeProvenance(subtree, |
| 21 | +'author' | 'policy')`, read positionally by |
| 22 | +`resolveFilterSubtreeProvenance(root, node)` (innermost mark on the ancestor |
| 23 | +chain wins; located by object identity, never structural equality). It rides |
| 24 | +the `where` tree by reference across the `DriverQuery` boundary — no new slot, |
| 25 | +documented on `DriverQuery` itself — and is dropped by exactly the operations |
| 26 | +(serialize, copy, rewrite) after which no attestation could be trusted. |
| 27 | + |
| 28 | +**Set at both read-scope merge boundaries**: `plugin-security`'s CRUD RLS |
| 29 | +injection marks every injected scope `'policy'` and the caller's verbatim |
| 30 | +predicate `'author'` — the latter only under the identity vouch |
| 31 | +`ast.where === options.where`, so a tree a sibling middleware already rewrote |
| 32 | +is vouched for nobody. `service-analytics`' `ObjectQLStrategy.withReadScope` |
| 33 | +marks its scope `'policy'` and the strategy-built user filter `'author'` (and |
| 34 | +`resolveFkAttr`'s scope arm `'policy'`). |
| 35 | + |
| 36 | +**Consumed by the SQL family** (`driver-sql`, `driver-turso`'s |
| 37 | +`RemoteTransport`; `driver-sqlite-wasm` inherits): a refusal raised from a |
| 38 | +subtree positively marked `'author'` carries its full diagnostic on the wire |
| 39 | +again — both columns, the operator, the list index, the boundary reason — |
| 40 | +same identity (`INVALID_FILTER` / 400). |
| 41 | + |
| 42 | +**⚠️ The fail direction is closed, and it is the design**: unmarked or |
| 43 | +ambiguous — no mark anywhere, a mark lost to serialization, a node |
| 44 | +unreachable from the query's own `where`, conflicting aliased marks — |
| 45 | +withholds exactly like `'policy'`. The mark is permission to reveal, never a |
| 46 | +requirement to prove secrecy; a driver-side guess at provenance is the shape |
| 47 | +the #7929 triage rejected. |
| 48 | + |
| 49 | +**Two B-era pins were REWRITTEN deliberately, not weakened.** First, |
| 50 | +`service-analytics`' `cross-field-engine-fallback.test.ts` pinned B's blanket |
| 51 | +redaction on refusals of the caller's OWN `where` (no scope in play) — under A |
| 52 | +that caller is the vouched author, so those cases now assert the corpus's |
| 53 | +`diagnosticIncludes` fragments are back on the wire, while the |
| 54 | +policy-injected-scope case gains the explicit non-disclosure assertions as its |
| 55 | +fail-closed pair. Second, the sharper one: |
| 56 | +`packages/runtime/src/cross-field-refusal-operand-withhold.test.ts` pinned |
| 57 | +author-written and policy-injected refusals **byte-identical** — the strongest |
| 58 | +available statement of "the driver cannot tell them apart", and explicitly the |
| 59 | +assertion A was chartered to supersede. Its successor pins the three-way split |
| 60 | +#8220's "Done means" names: policy-injected withholds (unchanged), the vouched |
| 61 | +author's filter names its columns again (the messages now differ, by design), |
| 62 | +and an unmarked predicate still withholds **byte-identical to the policy |
| 63 | +case** — B's surviving half. Reading that diff as a regression is exactly what |
| 64 | +the old pin's comment warned against; the file header carries the full |
| 65 | +account. |
| 66 | + |
| 67 | +Unaffected: the REST boundary's 5xx-only withhold (#5367/#5667) and every |
| 68 | +refusal outside the cross-field family. |
0 commit comments