From a8f8a51f1eb2986aee92b8ee5c87214ae0197fe6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 11:26:27 +0000 Subject: [PATCH 1/3] feat(spec): declare the read-scope filter-subtree provenance mark; set it at both merge boundaries; consume it in the SQL drivers (#8220) The mark (data/filter-subtree-provenance.ts) is set by plugin-security's CRUD RLS injection and service-analytics' ObjectQLStrategy.withReadScope, travels on the where tree by reference across the DriverQuery boundary, and is consumed by driver-sql / driver-turso's RemoteTransport: a subtree positively marked 'author' gets its full cross-field diagnostic back; 'policy', unmarked and ambiguous all keep the #7929/#8198 redaction (fail closed). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AkiE189Csm7aBT2qCVSx3X --- packages/drivers/driver-sql/src/sql-driver.ts | 231 +++++++++++++---- .../driver-turso/src/remote-transport.ts | 77 +++++- .../plugin-security/src/security-plugin.ts | 33 +++ .../src/strategies/objectql-strategy.ts | 22 +- packages/spec/src/contracts/data-driver.ts | 12 + .../data/filter-subtree-provenance.test.ts | 160 ++++++++++++ .../src/data/filter-subtree-provenance.ts | 241 ++++++++++++++++++ packages/spec/src/data/index.ts | 6 + 8 files changed, 735 insertions(+), 47 deletions(-) create mode 100644 packages/spec/src/data/filter-subtree-provenance.test.ts create mode 100644 packages/spec/src/data/filter-subtree-provenance.ts diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index a4c7a22cb2..a99dc86ac1 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -36,6 +36,9 @@ import { isNowDefaultToken, isRuntimeDefaultToken } from '@objectstack/spec/data // sentence about `$regex` are five sentences that drift apart. This driver // prints `why` VERBATIM. import { RETIRED_FILTER_OPERATORS } from '@objectstack/spec/data'; +// [#8220] The read-scope provenance mark's consumer half: where a cross-field +// refusal decides whether its full diagnostic may reach the caller. +import { resolveFilterSubtreeProvenance } from '@objectstack/spec/data'; // [#7872] The shared comparand-type door: the accepted six-type SET (this // driver's own allowlists delegate their membership to it) and the sentence // its refusals quote, so the set has one home instead of a copy per driver. @@ -599,6 +602,25 @@ const WITHHELD_FILTER_DIAGNOSTIC = Symbol.for('objectstack.driver-sql.withheldFi */ const WITHHELD_FILTER_LOGGED = Symbol.for('objectstack.driver-sql.withheldFilterLogged'); +/** + * [#8220] The filter subtree the refusal was raised FROM — the reference the + * throw site held — so {@link SqlDriver.resolveWithheldFilterRefusal} can ask + * the spec's provenance resolver where that node sits under the query's own + * `where` root. A symbol for the same non-travel reason as the diagnostic: + * nothing that serialises or spreads the error can carry a live tree reference + * to a place that could misread it. + */ +const WITHHELD_FILTER_SUBTREE = Symbol.for('objectstack.driver-sql.withheldFilterSubtree'); + +/** + * [#8220] The full author-facing message of a refusal whose subtree turns out + * to be positively marked `'author'` — the diagnostic plus the repair + * prescription the redacted message states only generically. Kept beside the + * diagnostic rather than derived from it at resolution time so each builder + * owns ONE composition of its own wording, redacted and restored alike. + */ +const WITHHELD_FILTER_AUTHOR_TEXT = Symbol.for('objectstack.driver-sql.withheldFilterAuthorText'); + /** * [#7929, maintainer ruling 2026-08-12, verbatim: 「接受你的全部建议。」 adopting * "B now, A next"] An `INVALID_FILTER` refusal that answers the caller with @@ -627,17 +649,39 @@ const WITHHELD_FILTER_LOGGED = Symbol.for('objectstack.driver-sql.withheldFilter * * # The accepted cost, named rather than hidden * - * An author debugging their OWN cross-field filter now gets the redacted - * message too, and that is a real diagnostic regression B pays for containment. - * #7929's follow-up card (A: a spec-declared provenance mark set at both merge - * boundaries) is what restores the author-facing text behind a real mark. - * ⛔ Do not "fix" this by re-adding the names, and ⛔ do not widen the REST - * boundary's 5xx-only withhold to 4xx instead (ruled out: it would delete - * #5367's tiering and #5667's legible-undeclared-5xx decision). + * An author debugging their OWN cross-field filter used to get the redacted + * message too — B's accepted cost. [#8220] (A of the same ruling) is what pays + * it back: the refusal carries the subtree it was raised from, and + * {@link SqlDriver.resolveWithheldFilterRefusal} swaps in the full diagnostic + * when — and ONLY when — that subtree is positively marked `'author'` by a + * read-scope merge boundary (`markFilterSubtreeProvenance`, + * `@objectstack/spec/data`). Unmarked or ambiguous stays exactly here, on the + * withholding branch — the mark is permission to reveal, never a duty to prove + * secrecy. ⛔ Do not "fix" the remaining redaction by re-adding the names + * unconditionally, and ⛔ do not widen the REST boundary's 5xx-only withhold + * to 4xx instead (ruled out: it would delete #5367's tiering and #5667's + * legible-undeclared-5xx decision). + * + * `subtree` is the node the throw site held (the reference comparand, the + * list member, the field-spec map). `authorText` is what an `'author'` verdict + * puts on the wire; it defaults to `diagnostic` so no builder can end up with + * a restored message emptier than its own server-log line. */ -function withheldFilterError(message: string, diagnostic: string): Error { +function withheldFilterError( + message: string, + diagnostic: string, + subtree?: unknown, + authorText?: string, +): Error { const err = unsupportedFilterError(message); Object.defineProperty(err, WITHHELD_FILTER_DIAGNOSTIC, { value: diagnostic, enumerable: false }); + if (subtree !== null && typeof subtree === 'object') { + Object.defineProperty(err, WITHHELD_FILTER_SUBTREE, { value: subtree, enumerable: false }); + } + Object.defineProperty(err, WITHHELD_FILTER_AUTHOR_TEXT, { + value: authorText ?? diagnostic, + enumerable: false, + }); return err; } @@ -1109,10 +1153,21 @@ function fieldReferenceOf(value: unknown): string | null { * * [#7929] The operands, the operator and the list index are withheld from the * caller-visible half and kept in the server-log half — see - * {@link withheldFilterError} for why, and for what stays. + * {@link withheldFilterError} for why, and for what stays. [#8220] `subtree` + * is the offending node itself, so a positively author-marked filter gets the + * naming half back. */ -function crossFieldComparisonError(field: string, op: string, ref: string, index?: number): Error { +function crossFieldComparisonError( + field: string, + op: string, + ref: string, + subtree?: unknown, + index?: number, +): Error { const position = index === undefined ? '' : ` at index ${index} of its value list`; + const diagnostic = + `Operator "${op}" on field "${field}" compares against another field ` + + `({ "$field": "${ref}" })${position}, a position SQL push-down does not compile.`; return withheldFilterError( `A cross-field comparison ({ "$field": … }) in this filter sits in a position SQL ` + `push-down does not compile. Cross-field comparison compiles only as the whole comparand ` + @@ -1120,8 +1175,11 @@ function crossFieldComparisonError(field: string, op: string, ref: string, index `declared columns. Compare against a literal value here, or evaluate the rule in memory ` + `(matchesFilter). The columns and the operator this filter used are withheld from the ` + `message (#7929); the full diagnostic is in the server log.`, - `Operator "${op}" on field "${field}" compares against another field ` + - `({ "$field": "${ref}" })${position}, a position SQL push-down does not compile.`, + diagnostic, + subtree, + `${diagnostic} Cross-field comparison compiles only as the whole comparand of a scalar ` + + `comparison operator ($eq/$ne/$gt/$gte/$lt/$lte) between two same-table declared columns. ` + + `Compare against a literal value here, or evaluate the rule in memory (matchesFilter).`, ); } @@ -1158,7 +1216,11 @@ function crossFieldComparisonError(field: string, op: string, ref: string, index * on a read-scope refusal hands the administrator's policy back to the tenant * as a suggestion. The SHAPE survives the redaction; the names do not. */ -function bareFieldReferenceError(field: string, ref: string): Error { +function bareFieldReferenceError(field: string, ref: string, subtree?: unknown): Error { + const diagnostic = + `Field "${field}" is constrained by a bare field reference ({ "$field": "${ref}" }) with no ` + + `operator. The spelling that compiles is ` + + `{ "${field}": { "$eq": { "$field": "${ref}" } } }.`; return withheldFilterError( `A field in this filter is constrained by a bare field reference ({ "$field": … }) with no ` + `operator. Write the comparison explicitly — { "TARGET_FIELD": { "$eq": { "$field": ` + @@ -1167,9 +1229,11 @@ function bareFieldReferenceError(field: string, ref: string): Error { `record), so compiling it here would make the two execution paths answer this filter ` + `differently. The columns this filter named are withheld from the message (#7929); the ` + `full diagnostic is in the server log.`, - `Field "${field}" is constrained by a bare field reference ({ "$field": "${ref}" }) with no ` + - `operator. The spelling that compiles is ` + - `{ "${field}": { "$eq": { "$field": "${ref}" } } }.`, + diagnostic, + subtree, + `${diagnostic} The bare form is refused because the in-memory evaluator does not read it as ` + + `an equality (it matches no record), so compiling it here would make the two execution ` + + `paths answer this filter differently.`, ); } @@ -1256,15 +1320,24 @@ function crossFieldComparisonClass( * contract statement that followed it names nothing and stays on the wire, so a * caller still learns the capability boundary without learning this filter. */ -function uncompilableFieldReferenceError(field: string, op: string, ref: string, reason: string): Error { +function uncompilableFieldReferenceError( + field: string, + op: string, + ref: string, + reason: string, + subtree?: unknown, +): Error { + const diagnostic = + `Operator "${op}" on field "${field}" compares against another field ` + + `({ "$field": "${ref}" }), which cannot be compiled here: ${reason}`; return withheldFilterError( `A cross-field comparison ({ "$field": … }) in this filter cannot be compiled here. ` + `Cross-field comparison on SQL push-down supports same-table columns the object ` + `declares, compared as the same type class, excluding the tenant-isolation column. ` + `The columns, the operator this filter used and the specific reason are withheld from ` + `the message (#7929); the full diagnostic is in the server log.`, - `Operator "${op}" on field "${field}" compares against another field ` + - `({ "$field": "${ref}" }), which cannot be compiled here: ${reason}`, + diagnostic, + subtree, ); } @@ -1440,7 +1513,7 @@ function isRenderableTextComparand(value: unknown): boolean { */ function assertCompilableComparand(field: string, op: string, value: unknown): void { const ref = fieldReferenceOf(value); - if (ref !== null) throw crossFieldComparisonError(field, op, ref); + if (ref !== null) throw crossFieldComparisonError(field, op, ref, value); // [#5234] The pattern family answers first: an array IS an object here, so // the member scan below would otherwise report `{$contains: ['a', {}]}` as a @@ -1452,7 +1525,7 @@ function assertCompilableComparand(field: string, op: string, value: unknown): v if (Array.isArray(value)) { for (const [index, member] of value.entries()) { const memberRef = fieldReferenceOf(member); - if (memberRef !== null) throw crossFieldComparisonError(field, op, memberRef, index); + if (memberRef !== null) throw crossFieldComparisonError(field, op, memberRef, member, index); // [#5234] Every member of a list operator's array is a comparand in its // own right and gets the same bind test the whole comparand gets. Scoped // to the operators for which an array is legitimate, so a scalar operator @@ -2388,7 +2461,7 @@ function classifyFilterKey(key: string, value: unknown, here: string): FilterVer for (const [op, comparand] of Object.entries(value)) { if (CROSS_FIELD_COMPARISON_OPERATORS.has(op)) continue; const ref = fieldReferenceOf(comparand); - if (ref !== null) throw crossFieldComparisonError(key, op, ref); + if (ref !== null) throw crossFieldComparisonError(key, op, ref, comparand); } } @@ -8743,11 +8816,54 @@ export class SqlDriver implements IDataDriver { try { this.compileFilters(builder, filters); } catch (err) { - this.logWithheldFilterDiagnostic(err); - throw err; + throw this.resolveWithheldFilterRefusal(err, filters); } } + /** + * [#8220] Decide what a redacted refusal may say, now that the tree can + * carry the read-scope provenance mark — and return the error to throw. + * + * A refusal raised from a subtree POSITIVELY marked `'author'` (by + * `plugin-security`'s CRUD injection or `service-analytics`' + * `ObjectQLStrategy.withReadScope` — the two boundaries that know which arm + * of their merge the caller wrote) is swapped for its full author-facing + * text: both columns, the operator, the list index, the boundary reason. + * Same identity — `INVALID_FILTER` / 400 — different words. + * + * ⚠️ Everything else stays REDACTED and goes to the server log, exactly as + * #7929/B left it: a `'policy'` verdict, an UNMARKED tree (no boundary ever + * vouched), an AMBIGUOUS one (the node is unreachable from this query's + * `where` — e.g. rewritten between merge and refusal — or aliased under + * conflicting marks), and an error that never carried its subtree at all. + * The mark is permission to reveal, never a requirement to prove secrecy; + * inverting that default — treating "mark missing" as "safe to disclose" — + * silently restores the original disclosure on every unmarked policy + * predicate and is the one shape the #7929 triage rejected. Do not + * "simplify" the `=== 'author'` reading below into anything that discloses + * on a missing mark. + * + * The disclosed error is a fresh `unsupportedFilterError` carrying NO + * symbol keys: there is no diagnostic left to relocate, so the log seam has + * nothing to write for it, and an outer frame that resolves an + * already-resolved error passes it through untouched (the double-catch + * shape {@link SqlDriver.withWithheldFilterLog} exists for). + */ + protected resolveWithheldFilterRefusal(err: unknown, rootFilter: unknown): unknown { + const diagnostic = withheldFilterDiagnosticOf(err); + if (diagnostic === null) return err; + const carrier = err as Record; + const subtree = carrier[WITHHELD_FILTER_SUBTREE]; + const provenance = + subtree === undefined ? null : resolveFilterSubtreeProvenance(rootFilter, subtree); + if (provenance === 'author') { + const authorText = carrier[WITHHELD_FILTER_AUTHOR_TEXT]; + return unsupportedFilterError(typeof authorText === 'string' ? authorText : diagnostic); + } + this.logWithheldFilterDiagnostic(err); + return err; + } + /** * [#7929] Write the withheld half of a redacted refusal to the server log. * @@ -8781,12 +8897,17 @@ export class SqlDriver implements IDataDriver { * halves. {@link logWithheldFilterDiagnostic} marks the error, so an error * that passes two of these frames is still logged once. */ - protected withWithheldFilterLog(fn: () => T): T { + protected withWithheldFilterLog(rootFilter: unknown, fn: () => T): T { try { return fn(); } catch (err) { - this.logWithheldFilterDiagnostic(err); - throw err; + // [#8220] The same provenance resolution `applyFilters` applies, against + // the same `where` ROOT — threaded into this frame because a lazily + // invoked group callback is past that entry point's catch. Resolving + // against the local branch instead would lose every mark set on an + // ANCESTOR (the merge boundaries mark the arms of their own `$and`), so + // an author's nested `$or` refusal would stay redacted for no reason. + throw this.resolveWithheldFilterRefusal(err, rootFilter); } } @@ -8825,7 +8946,7 @@ export class SqlDriver implements IDataDriver { ); if (hasMongoOperators) { - this.applyFilterCondition(builder, filters, 'and', table); + this.applyFilterCondition(builder, filters, 'and', table, filters); return; } @@ -9062,8 +9183,19 @@ export class SqlDriver implements IDataDriver { * compiled exactly as it always was — `{ a: 1 }` is `a = 1`, `$in` is `in (…)` * — so nothing on the majority path changed shape. */ - protected applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp: 'and' | 'or' = 'and', tableHint?: string | null) { + protected applyFilterCondition( + builder: Knex.QueryBuilder, + condition: any, + logicalOp: 'and' | 'or' = 'and', + tableHint?: string | null, + // [#8220] The query's `where` ROOT, threaded so the lazily invoked group + // callbacks below can resolve a refusal's provenance against the tree the + // merge boundaries actually marked. Defaults to this frame's own + // `condition` for any direct caller compiling a standalone tree. + rootFilter?: unknown, + ) { if (!condition || typeof condition !== 'object') return; + const root = rootFilter ?? condition; const table = tableHint ?? this.coercionKey(builder); // #5134 — shape-validate the whole tree and decide its boolean value first. @@ -9093,7 +9225,7 @@ export class SqlDriver implements IDataDriver { (builder as any)[method]((qb: any) => { for (const sub of branches) { qb.where((subQb: any) => { - this.withWithheldFilterLog(() => this.applyFilterCondition(subQb, sub, 'and', table)); + this.withWithheldFilterLog(root, () => this.applyFilterCondition(subQb, sub, 'and', table, root)); }); } }); @@ -9118,7 +9250,7 @@ export class SqlDriver implements IDataDriver { // read scope of the shape `{$or:[{owner,status},{shared_with}]}` // returned rows the scope excluded — see sql-driver-or-filter.test.ts. qb.orWhere((subQb: any) => { - this.withWithheldFilterLog(() => this.applyFilterCondition(subQb, sub, 'and', table)); + this.withWithheldFilterLog(root, () => this.applyFilterCondition(subQb, sub, 'and', table, root)); }); } }); @@ -9144,7 +9276,12 @@ export class SqlDriver implements IDataDriver { const negated = nullSafeNegationOperand(value as Record); const notMethod = logicalOp === 'or' ? 'orWhereNot' : 'whereNot'; (builder as any)[notMethod]((qb: any) => { - this.withWithheldFilterLog(() => this.applyFilterCondition(qb, negated, 'and', table)); + // [#8220] `negated` is a REWRITE — its nodes are new objects the + // provenance resolver cannot find under `root`, so a cross-field + // refusal inside a `$not` resolves ambiguous and stays withheld, + // for the author too. Fail-closed by construction; the walk-side + // refusals (raised on the ORIGINAL nodes, eagerly) are unaffected. + this.withWithheldFilterLog(root, () => this.applyFilterCondition(qb, negated, 'and', table, root)); }); } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { const localField = this.mapSortField(key); @@ -9166,11 +9303,14 @@ export class SqlDriver implements IDataDriver { // unsupported-operator arm, so the message can name the supported // spelling instead of listing fifteen operator names. if (rawOp === '$field' && typeof opValue === 'string') { - throw bareFieldReferenceError(field, opValue); + // [#8220] `value` — the field-spec map carrying the bare `$field` + // key — is the node the provenance resolver can find in the tree; + // `opValue` is only the referenced column's name. + throw bareFieldReferenceError(field, opValue, value); } const crossFieldRef = fieldReferenceOf(opValue); if (crossFieldRef !== null && CROSS_FIELD_COMPARISON_OPERATORS.has(rawOp)) { - this.applyCrossFieldComparison(builder, method, table, key, localField, field, rawOp, crossFieldRef); + this.applyCrossFieldComparison(builder, method, table, key, localField, field, rawOp, crossFieldRef, opValue); continue; } // #5041 — reject a comparand that cannot become a bind parameter @@ -9434,40 +9574,43 @@ export class SqlDriver implements IDataDriver { targetColumn: string, op: string, ref: string, + // [#8220] The `{ $field }` comparand node itself, threaded so each refusal + // below can carry the subtree the provenance resolver locates in the tree. + refNode?: unknown, ): void { if (ref.includes('.')) { throw uncompilableFieldReferenceError(targetColumn, op, ref, `"${ref}" is a dotted path, and SQL push-down compiles same-table column references ` + - `only (no relation traversal, no alias-qualified columns).`); + `only (no relation traversal, no alias-qualified columns).`, refNode); } if (!table) { throw uncompilableFieldReferenceError(targetColumn, op, ref, `the target table of this query could not be resolved, so the reference cannot be ` + - `checked against any declared column set.`); + `checked against any declared column set.`, refNode); } const declared = this.declaredFieldsFor(table); if (!declared) { throw uncompilableFieldReferenceError(targetColumn, op, ref, `object "${table}" has no declared column set on this driver (an external/federated ` + - `or unregistered table), so referenced column names cannot be validated.`); + `or unregistered table), so referenced column names cannot be validated.`, refNode); } const tenantField = this.resolveTenantField(table); if (tenantField !== null && (ref === tenantField || targetKey === tenantField || targetLocalField === tenantField)) { throw uncompilableFieldReferenceError(targetColumn, op, ref, `"${tenantField}" is the tenant-isolation column of "${table}", which must not appear ` + - `on either side of a cross-field comparison.`); + `on either side of a cross-field comparison.`, refNode); } const hasOwn = (name: string) => Object.prototype.hasOwnProperty.call(declared, name); const refDeclaredName = hasOwn(ref) ? ref : this.mapSortField(ref); if (!hasOwn(refDeclaredName)) { throw uncompilableFieldReferenceError(targetColumn, op, ref, - `"${ref}" is not a declared field of "${table}" — only declared fields can be referenced.`); + `"${ref}" is not a declared field of "${table}" — only declared fields can be referenced.`, refNode); } const targetDeclaredName = hasOwn(targetKey) ? targetKey : targetLocalField; if (!hasOwn(targetDeclaredName)) { throw uncompilableFieldReferenceError(targetColumn, op, ref, `the target field "${targetKey}" is not a declared field of "${table}", so the two ` + - `columns' types cannot be checked as comparable.`); + `columns' types cannot be checked as comparable.`, refNode); } const refClass = crossFieldComparisonClass(declared[refDeclaredName] ?? {}); const targetClass = crossFieldComparisonClass(declared[targetDeclaredName] ?? {}); @@ -9475,20 +9618,20 @@ export class SqlDriver implements IDataDriver { throw uncompilableFieldReferenceError(targetColumn, op, ref, `"${ref}" (type "${String(declared[refDeclaredName]?.type ?? 'string')}"` + `${declared[refDeclaredName]?.multiple ? ', multiple' : ''}) has no scalar stored ` + - `column a comparison can read.`); + `column a comparison can read.`, refNode); } if (targetClass === null) { throw uncompilableFieldReferenceError(targetColumn, op, ref, `the target field "${targetKey}" (type ` + `"${String(declared[targetDeclaredName]?.type ?? 'string')}"` + `${declared[targetDeclaredName]?.multiple ? ', multiple' : ''}) has no scalar stored ` + - `column a comparison can read.`); + `column a comparison can read.`, refNode); } if (refClass !== targetClass) { throw uncompilableFieldReferenceError(targetColumn, op, ref, `"${targetKey}" is stored as ${targetClass} but "${ref}" as ${refClass}, and a ` + `cross-class comparison answers differently in SQL (storage-class ordering) than in ` + - `memory (JS coercion) — compare same-class columns.`); + `memory (JS coercion) — compare same-class columns.`, refNode); } const refLocal = this.mapSortField(ref); @@ -9536,7 +9679,7 @@ export class SqlDriver implements IDataDriver { } default: // Unreachable: the call site gates on CROSS_FIELD_COMPARISON_OPERATORS. - throw crossFieldComparisonError(targetColumn, op, ref); + throw crossFieldComparisonError(targetColumn, op, ref, refNode); } } diff --git a/packages/drivers/driver-turso/src/remote-transport.ts b/packages/drivers/driver-turso/src/remote-transport.ts index 0c93c64ebf..b4c4c5aa8b 100644 --- a/packages/drivers/driver-turso/src/remote-transport.ts +++ b/packages/drivers/driver-turso/src/remote-transport.ts @@ -28,6 +28,9 @@ import { // `SqlDriver`'s local emitter so this transport and its local twin cannot fork // on what a pattern means (the fork `turso-local-remote-*` suites exist to catch). import { hasDanglingLikeEscape, likePatternToGlobPattern } from '@objectstack/spec/data'; +// [#8220] The read-scope provenance mark's consumer half — same resolution the +// SqlDriver family applies, so which transport answered stays unobservable. +import { resolveFilterSubtreeProvenance } from '@objectstack/spec/data'; // The DECLARED aggregate vocabulary (#5907) — read from the spec so this // transport's "the protocol has no such function" refusal cannot drift from what // `AggregationNodeSchema.function` admits, nor from the local driver's twin. @@ -561,6 +564,16 @@ function invalidFilterError(message: string): Error { */ const WITHHELD_FILTER_DIAGNOSTIC = Symbol.for('objectstack.driver-sql.withheldFilterDiagnostic'); +/** + * [#8220] The other two keys of `driver-sql`'s withheld-refusal carrier, under + * the SAME global-registry names for the same reason as the diagnostic above: + * one deployment, two compilers, one error shape. `…Subtree` holds the filter + * node the refusal was raised from; `…AuthorText` holds the full message an + * `'author'`-marked subtree gets back. + */ +const WITHHELD_FILTER_SUBTREE = Symbol.for('objectstack.driver-sql.withheldFilterSubtree'); +const WITHHELD_FILTER_AUTHOR_TEXT = Symbol.for('objectstack.driver-sql.withheldFilterAuthorText'); + /** * [#7929, maintainer ruling 2026-08-12] An `INVALID_FILTER` whose caller-visible * message is `message` and whose operand-naming half is `diagnostic`. @@ -574,9 +587,21 @@ const WITHHELD_FILTER_DIAGNOSTIC = Symbol.for('objectstack.driver-sql.withheldFi * withholds the same way. Leaving one mode disclosing would make the exposure a * property of the connection string. */ -function withheldInvalidFilterError(message: string, diagnostic: string): Error { +function withheldInvalidFilterError( + message: string, + diagnostic: string, + subtree?: unknown, + authorText?: string, +): Error { const err = invalidFilterError(message); Object.defineProperty(err, WITHHELD_FILTER_DIAGNOSTIC, { value: diagnostic, enumerable: false }); + if (subtree !== null && typeof subtree === 'object') { + Object.defineProperty(err, WITHHELD_FILTER_SUBTREE, { value: subtree, enumerable: false }); + } + Object.defineProperty(err, WITHHELD_FILTER_AUTHOR_TEXT, { + value: authorText ?? diagnostic, + enumerable: false, + }); return err; } @@ -1732,6 +1757,50 @@ export class RemoteTransport { object: string, filters: any, path = 'where', + ): { whereClauses: string; args: any[] } { + // [#8220] Resolve a redacted refusal's provenance at the OUTERMOST frame + // only — `path === 'where'` is true exactly for the five external call + // sites, and the root they hand over is the tree the read-scope merge + // boundaries marked. Recursive frames rethrow untouched so one refusal is + // resolved once, against the whole tree. Fail-closed like the SqlDriver + // seam: 'author' swaps in the full text; 'policy', unmarked, unreachable + // and ambiguous all keep the redaction. + if (path === 'where') { + try { + return this.compileWhereSQL(object, filters, path); + } catch (err) { + throw this.resolveWithheldFilterRefusal(err, filters); + } + } + return this.compileWhereSQL(object, filters, path); + } + + /** + * [#8220] The SqlDriver seam's remote twin: what a redacted refusal may say. + * `'author'` — positively marked by a merge boundary, resolved against this + * query's own `where` root — gets the full author-facing text back, same + * identity (`INVALID_FILTER` / 400). Everything else (policy, unmarked, + * ambiguous, no subtree carried) keeps the redaction; the mark is permission + * to reveal, never a requirement to prove secrecy. + */ + private resolveWithheldFilterRefusal(err: unknown, rootFilter: unknown): unknown { + if (err === null || (typeof err !== 'object' && typeof err !== 'function')) return err; + const carrier = err as Record; + if (typeof carrier[WITHHELD_FILTER_DIAGNOSTIC] !== 'string') return err; + const subtree = carrier[WITHHELD_FILTER_SUBTREE]; + const provenance = + subtree === undefined ? null : resolveFilterSubtreeProvenance(rootFilter, subtree); + if (provenance !== 'author') return err; + const authorText = carrier[WITHHELD_FILTER_AUTHOR_TEXT]; + return invalidFilterError( + typeof authorText === 'string' ? authorText : (carrier[WITHHELD_FILTER_DIAGNOSTIC] as string), + ); + } + + private compileWhereSQL( + object: string, + filters: any, + path: string, ): { whereClauses: string; args: any[] } { // "No filter" is spelled by ABSENCE, and that is the only spelling. All // five call sites hand this method `query?.where` (`query.where` in @@ -2994,6 +3063,9 @@ export class RemoteTransport { `[RemoteTransport] Cross-field comparison is not supported in remote mode: ${target} ` + `${shown} compares a column against another column instead of against a value.`; this.diagnosticSink?.(diagnostic); + // [#8220] `value` is the `{ $field }` node itself — the reference the + // provenance resolver can locate under the query's `where` root, so an + // author-marked subtree gets the naming half back at the entry seam. return withheldInvalidFilterError( `[RemoteTransport] Cross-field comparison is not supported in remote mode: this filter ` + `compares a column against another column instead of against a value. The query DSL ` + @@ -3003,6 +3075,9 @@ export class RemoteTransport { `after retrieval. The columns and the operator this filter used are withheld from the ` + `message (#7929); the full diagnostic is in the server log.`, diagnostic, + value, + `${diagnostic} No executor compiles this form in remote mode — compare against a ` + + `literal, or select both columns and compare after retrieval.`, ); } return invalidFilterError( diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 0834e900a8..bf9b12e0d6 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -4,6 +4,9 @@ import { Plugin, PluginContext, POSTURE_LADDER } from '@objectstack/core'; import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security'; import { describeHighPrivilegeBits, describeAnchorForbiddenBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; +// [#8220] The read-scope provenance mark: this middleware is one of the two +// merge boundaries that stamp it (see the RLS injection below). +import { markFilterSubtreeProvenance } from '@objectstack/spec/data'; // [#7414] The SHARED operation-message catalog #7307 built for the data path's // operation-level refusals. Second consumer, same mechanism — a second remedy // for one defect class is what that module exists to prevent. @@ -2233,6 +2236,36 @@ export class SecurityPlugin implements Plugin { const delCbp = await this.computeControlledByParentFilter(delegatorSets, opCtx.object, delegatorContext); if (delCbp) extra.push(delCbp); } + // [#8220, A of #7929] This is one of the two read-scope MERGE + // BOUNDARIES, and the only frame that knows which subtree the caller + // did not write — so the provenance mark is stamped here, before the + // shapes blur into one `$and`. + // + // - Every injected scope is marked `'policy'`: a cross-field refusal + // raised from inside it keeps the #7929 redaction. + // - The caller's own predicate is marked `'author'` — but ONLY when + // this middleware can positively vouch for it: `opCtx.ast.where` + // still IS `opCtx.options.where`, the caller's verbatim predicate + // (the same identity step 2.9 above leans on). If a sibling + // middleware (plugin-sharing) already composed its own filter in, + // or the engine rewrote the tree (context tokens), identity fails + // and NOTHING is vouched — the whole tree stays unmarked, which + // withholds. Fail closed: the mark is permission to reveal, never + // a guess; a wholesale mark on a tree that might contain another + // plugin's policy would disclose it. + // + // Marking is on the OBJECTS, so it survives the `$and` wrapper below + // and any sibling's later wrapping by reference. `markFilterSubtreeProvenance` + // never overwrites an existing mark and no-ops on frozen input. + for (const scope of extra) markFilterSubtreeProvenance(scope, 'policy'); + const callerWhere = opCtx.ast.where; + if ( + callerWhere && + typeof callerWhere === 'object' && + callerWhere === (opCtx.options as { where?: unknown } | undefined)?.where + ) { + markFilterSubtreeProvenance(callerWhere, 'author'); + } if (extra.length) { opCtx.ast.where = opCtx.ast.where ? { $and: [opCtx.ast.where, ...extra] } diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index edb293ffba..2776e93635 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -2,6 +2,9 @@ import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts'; import type { Cube } from '@objectstack/spec/data'; +// [#8220] The read-scope provenance mark: `withReadScope` below is one of the +// two merge boundaries that stamp it. +import { markFilterSubtreeProvenance } from '@objectstack/spec/data'; import type { AnalyticsStrategy, StrategyContext } from './types.js'; import { invalidFilterError, @@ -454,11 +457,21 @@ export class ObjectQLStrategy implements AnalyticsStrategy { filter: Record, ctx: StrategyContext, ): Record | undefined { - const userFilter = Object.keys(filter).length > 0 ? filter : undefined; + // [#8220, A of #7929] This is the second read-scope MERGE BOUNDARY (the + // first is plugin-security's CRUD injection), so the provenance mark is + // stamped here: the scope is `'policy'` — a cross-field refusal from + // inside it keeps the #7929 redaction — and the strategy-built user filter + // is `'author'`: every name in it came from the caller's own query + // (dimensions, measures, `where`, time windows) through this class's own + // compilation, which is exactly the vouch the mark declares. Unmarked + // content anywhere else stays withheld downstream, by the mark's declared + // fail direction. + const userFilter = + Object.keys(filter).length > 0 ? markFilterSubtreeProvenance(filter, 'author') : undefined; if (typeof ctx.getReadScope !== 'function') return userFilter; const scope = ctx.getReadScope(objectName); if (scope === undefined || scope === null) return userFilter; - const scopeFilter = scope as Record; + const scopeFilter = markFilterSubtreeProvenance(scope as Record, 'policy'); if (!userFilter) return scopeFilter; return { $and: [userFilter, scopeFilter] }; } @@ -694,6 +707,11 @@ export class ObjectQLStrategy implements AnalyticsStrategy { if (fkValues.length === 0 || typeof ctx.executeAggregate !== 'function') return map; const idFilter: Record = { id: { $in: fkValues } }; const scope = typeof ctx.getReadScope === 'function' ? ctx.getReadScope(refObject) : null; + // [#8220] Same boundary family as `withReadScope`: the scope arm is policy. + // `idFilter` is this method's own plumbing, not the caller's text — it + // stays unmarked, which withholds, and that is correct for a filter no + // author typed. + if (scope != null) markFilterSubtreeProvenance(scope, 'policy'); const filter = scope != null ? { $and: [idFilter, scope] } : idFilter; const rows = await ctx.executeAggregate(refObject, { groupBy: ['id', attr], diff --git a/packages/spec/src/contracts/data-driver.ts b/packages/spec/src/contracts/data-driver.ts index 0ea8233bda..67d80a7beb 100644 --- a/packages/spec/src/contracts/data-driver.ts +++ b/packages/spec/src/contracts/data-driver.ts @@ -37,6 +37,18 @@ import type { QueryAST } from '../data/query.zod.js'; * declaration would be lying about a value that is `undefined` at runtime. * No driver in this repository reads it; the object name arrives as argument * one, which is the whole point. + * + * [#8220] **Provenance crosses this boundary ON the `where` tree, not beside + * it.** A `where` subtree may carry the filter-subtree provenance mark + * (`data/filter-subtree-provenance.ts`) under its declared symbol key, stamped + * by a read-scope merge boundary to say who authored that subtree. This type + * deliberately grows no `provenance` slot for it: the merge produces one tree + * whose ARMS differ in provenance, so a positional slot out here would break + * the moment any layer re-shaped the filter — while the in-tree mark travels + * by reference and is dropped by exactly the operations (copy, serialize, + * rewrite) after which no attestation could be trusted anyway. A driver that + * consumes it MUST fail closed: unmarked or ambiguous reads as policy-authored + * (withhold), never as the author's. */ export type DriverQuery = Omit; diff --git a/packages/spec/src/data/filter-subtree-provenance.test.ts b/packages/spec/src/data/filter-subtree-provenance.test.ts new file mode 100644 index 0000000000..c375212bcf --- /dev/null +++ b/packages/spec/src/data/filter-subtree-provenance.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8220] Filter-subtree provenance — the mark's declared semantics, pinned + * where they are declared. + * + * The one property everything here defends is the FAIL DIRECTION: unmarked or + * ambiguous answers `null`, and `null` withholds. Every degraded shape — a + * frozen subtree, a serialized round-trip, a corrupted mark value, a rewritten + * tree, an aliased node under conflicting arms — must land on `null`, never on + * `'author'`. + */ + +import { describe, it, expect } from 'vitest'; + +import { + FILTER_SUBTREE_PROVENANCE, + filterSubtreeProvenanceOf, + markFilterSubtreeProvenance, + resolveFilterSubtreeProvenance, +} from './filter-subtree-provenance'; + +describe('markFilterSubtreeProvenance / filterSubtreeProvenanceOf', () => { + it('marks a subtree in place, invisibly to enumeration and JSON', () => { + const subtree = { amount: { $gt: { $field: 'budget' } } }; + const returned = markFilterSubtreeProvenance(subtree, 'author'); + expect(returned).toBe(subtree); + expect(filterSubtreeProvenanceOf(subtree)).toBe('author'); + // The mark must not change what any schema or serializer sees. + expect(Object.keys(subtree)).toEqual(['amount']); + expect(JSON.stringify(subtree)).toBe('{"amount":{"$gt":{"$field":"budget"}}}'); + }); + + it('the global-registry symbol is the declared key', () => { + const subtree = markFilterSubtreeProvenance({ a: 1 }, 'policy'); + expect((subtree as Record)[Symbol.for('objectstack.filter.subtreeProvenance')]).toBe('policy'); + expect(FILTER_SUBTREE_PROVENANCE).toBe(Symbol.for('objectstack.filter.subtreeProvenance')); + }); + + it('first mark wins — a later actor cannot flip a policy subtree to author', () => { + const scope = markFilterSubtreeProvenance({ organization_id: 'org_1' }, 'policy'); + markFilterSubtreeProvenance(scope, 'author'); + expect(filterSubtreeProvenanceOf(scope)).toBe('policy'); + }); + + it('a frozen subtree stays unmarked (withheld), and the boundary does not throw', () => { + const frozen = Object.freeze({ stage: 'won' }); + expect(() => markFilterSubtreeProvenance(frozen, 'author')).not.toThrow(); + expect(filterSubtreeProvenanceOf(frozen)).toBe(null); + }); + + it('non-object subtrees and undeclared mark values degrade to unmarked', () => { + expect(markFilterSubtreeProvenance(null, 'author')).toBe(null); + expect(filterSubtreeProvenanceOf(null)).toBe(null); + expect(filterSubtreeProvenanceOf('author')).toBe(null); + const corrupted: Record = {}; + corrupted[FILTER_SUBTREE_PROVENANCE] = 'administrator'; // not a declared literal + expect(filterSubtreeProvenanceOf(corrupted)).toBe(null); + const wrongPolarity = markFilterSubtreeProvenance({}, 'administrator' as never); + expect(filterSubtreeProvenanceOf(wrongPolarity)).toBe(null); + }); + + it('a serialization round-trip DROPS the mark — the wire cannot smuggle a vouch', () => { + const marked = markFilterSubtreeProvenance({ amount: { $gt: 5 } }, 'author'); + const roundTripped = JSON.parse(JSON.stringify(marked)); + expect(filterSubtreeProvenanceOf(roundTripped)).toBe(null); + }); +}); + +describe('resolveFilterSubtreeProvenance', () => { + /** The exact shape both merge boundaries produce. */ + const merged = () => { + const authorWhere = markFilterSubtreeProvenance( + { amount: { $gt: { $field: 'budget' } } }, + 'author', + ); + const scope = markFilterSubtreeProvenance( + { stage: { $eq: { $field: 'organization_id' } } }, + 'policy', + ); + return { root: { $and: [authorWhere, scope] }, authorWhere, scope }; + }; + + it('a node inside the author arm resolves author; inside the policy arm, policy', () => { + const { root, authorWhere, scope } = merged(); + const authorComparand = (authorWhere.amount as Record).$gt; + const policyComparand = (scope.stage as Record).$eq; + expect(resolveFilterSubtreeProvenance(root, authorComparand as object)).toBe('author'); + expect(resolveFilterSubtreeProvenance(root, policyComparand as object)).toBe('policy'); + // The arms themselves answer their own marks. + expect(resolveFilterSubtreeProvenance(root, authorWhere)).toBe('author'); + expect(resolveFilterSubtreeProvenance(root, scope)).toBe('policy'); + }); + + it('the INNERMOST mark wins — policy nested under a vouched author root stays policy', () => { + const scope = markFilterSubtreeProvenance({ secret: { $eq: 1 } }, 'policy'); + const root = markFilterSubtreeProvenance({ $and: [{ stage: 'won' }, scope] }, 'author'); + expect(resolveFilterSubtreeProvenance(root, scope.secret as object)).toBe('policy'); + // …while the sibling arm inherits the root's vouch. + const authorArm = (root.$and as object[])[0] as { stage: unknown }; + expect(resolveFilterSubtreeProvenance(root, authorArm)).toBe('author'); + }); + + it('an unmarked tree resolves null — no boundary ever vouched, so withheld', () => { + const root = { $and: [{ amount: { $gt: { $field: 'budget' } } }] }; + const node = (root.$and[0] as { amount: Record }).amount.$gt; + expect(resolveFilterSubtreeProvenance(root, node as object)).toBe(null); + }); + + it('a node NOT reachable from the root resolves null — a rewritten tree cannot disclose', () => { + const { root } = merged(); + const rewrittenCopy = { $field: 'budget' }; // structurally equal, different identity + expect(resolveFilterSubtreeProvenance(root, rewrittenCopy)).toBe(null); + }); + + it('a node aliased into arms with CONFLICTING effective marks resolves null (ambiguous)', () => { + const shared = { amount: { $gt: { $field: 'budget' } } }; + const root = { + $and: [ + markFilterSubtreeProvenance({ $and: [shared] }, 'author'), + markFilterSubtreeProvenance({ $or: [shared] }, 'policy'), + ], + }; + expect(resolveFilterSubtreeProvenance(root, shared)).toBe(null); + }); + + it('a node aliased into arms that AGREE resolves that agreement', () => { + const shared = { amount: { $gt: 1 } }; + const root = { + $and: [ + markFilterSubtreeProvenance({ $and: [shared] }, 'author'), + markFilterSubtreeProvenance({ $or: [shared] }, 'author'), + ], + }; + expect(resolveFilterSubtreeProvenance(root, shared)).toBe('author'); + }); + + it('terminates on cyclic input and answers for what it could reach', () => { + const cyclic: Record = { a: 1 }; + cyclic.self = cyclic; + const target = markFilterSubtreeProvenance({ b: 2 }, 'author'); + const root = { $and: [cyclic, target] }; + expect(resolveFilterSubtreeProvenance(root, target)).toBe('author'); + expect(resolveFilterSubtreeProvenance(root, { never: 'seen' })).toBe(null); + }); + + it('does not walk into non-plain objects — a Date comparand holds no subtree', () => { + const target = { $gt: 1 }; + const root = { when: new Date(0), nested: { target } }; + expect(resolveFilterSubtreeProvenance(root, target)).toBe(null); // unmarked path + const markedRoot = markFilterSubtreeProvenance(root, 'author'); + expect(resolveFilterSubtreeProvenance(markedRoot, target)).toBe('author'); + }); + + it('degrades to null on non-object root or target', () => { + expect(resolveFilterSubtreeProvenance(null, {})).toBe(null); + expect(resolveFilterSubtreeProvenance({}, null)).toBe(null); + expect(resolveFilterSubtreeProvenance('root' as never, {})).toBe(null); + }); +}); diff --git a/packages/spec/src/data/filter-subtree-provenance.ts b/packages/spec/src/data/filter-subtree-provenance.ts new file mode 100644 index 0000000000..ae7feecd09 --- /dev/null +++ b/packages/spec/src/data/filter-subtree-provenance.ts @@ -0,0 +1,241 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8220, A of the #7929 maintainer ruling 2026-08-12 — 「接受你的全部建议。」, + * adopting "B now, A next"] FILTER-SUBTREE provenance — the spec-declared mark + * saying *who authored this predicate subtree*, set where a read scope is + * merged into a caller's `where` and consumed where a refusal decides what its + * message may name. + * + * ## The problem this declares an answer to + * + * B (#8198) made `driver-sql`'s cross-field `{ $field }` refusal withhold its + * operands from EVERY caller, because the predicate reaches the driver as a + * bare `FilterCondition` with nothing marking which subtree the caller did not + * write — an administrator's CEL sharing/permission rule and the author's own + * filter are indistinguishable there. The accepted cost was the author's own + * diagnostic. This module is the mark that gives it back: the two read-scope + * merge boundaries (`plugin-security`'s CRUD injection, + * `service-analytics`' `ObjectQLStrategy.withReadScope`) stamp each side of + * the merge, and the driver's refusal discloses its full diagnostic only for a + * subtree positively marked `'author'`. + * + * ## ⚠️ The fail direction is CLOSED, and it is load-bearing + * + * **Unmarked or ambiguous ⇒ withheld.** The mark is permission to *reveal*, + * never a requirement to prove secrecy. Every degradation in this module is + * designed to land on the withholding branch: + * + * - the mark lives under a SYMBOL key, so `JSON.stringify`, `{ ...spread }` + * of enumerable keys only, structured clone and the wire all DROP it — a + * filter that crossed a serialization boundary arrives unmarked and is + * withheld; + * - {@link markFilterSubtreeProvenance} never overwrites an existing mark and + * silently no-ops on a frozen or non-object subtree — a subtree that could + * not be marked stays unmarked and is withheld; + * - {@link filterSubtreeProvenanceOf} answers `null` for any value that is + * not exactly one of the two declared literals — a corrupted mark is + * withheld; + * - {@link resolveFilterSubtreeProvenance} answers `null` for a node it + * cannot find under the root, and for a node reachable under conflicting + * effective marks — an ambiguous position is withheld. + * + * A consumer must treat `null` exactly as it treats `'policy'`. Any design + * where "the mark is missing" lands on the disclosing branch re-opens the + * #7929 disclosure — including the sentence naming which column is the + * object's tenant-isolation column — and was rejected by the ruling this + * module implements. + * + * ## Why a symbol on the subtree, not a slot beside it + * + * The predicate crosses two package boundaries by REFERENCE (`QueryAST.where` + * → engine middleware → `DriverQuery.where`), and the merge produces one tree + * whose ARMS have different provenance — `{ $and: [authorWhere, scope] }`. A + * parallel slot on {@link import('../contracts/data-driver').DriverQuery} + * would have to describe subtree positions from outside the tree and would + * break the moment any layer re-shaped the filter; a mark ON the subtree + * travels with it, and is dropped by exactly the operations that would have + * invalidated a positional description (copy, serialize, rewrite) — which is + * the fail-closed direction above, by construction. `Symbol.for` (the global + * registry) rather than a module-local symbol so a duplicated copy of this + * package resolves the same key — the same choice `driver-sql` made for its + * withheld-diagnostic carrier. + * + * ## Why this is a separate mechanism from injected-column provenance (#7865) + * + * `injected-system-column-provenance.ts` answers a question about METADATA at + * rest — "is this column on this object document actually provisioned by the + * platform?" — derived from document-declared keys, stable for the life of the + * document. This module answers a question about one QUERY VALUE in flight — + * "who authored this predicate subtree?" — knowable only at the merge moment, + * by the code doing the merging, and meaningless once the query is done. One + * is a derivation over declared state; the other is an attestation attached at + * a boundary. Folding them into one mechanism would give the column verdict a + * mutable runtime carrier it must not have, and the filter mark a document + * derivation it cannot have. + * + * Like everything in this directory the module is tolerant of bare, un-parsed + * input: every function accepts `unknown` and degrades toward `null`. + */ + +/** + * Who authored a predicate subtree. + * + * - `'author'` — the caller's own predicate: the merge boundary attests the + * caller wrote (or could read back) every name in it. A refusal raised from + * inside it may carry its full diagnostic — the operands, the operator, the + * list index, the boundary reason. + * - `'policy'` — injected policy the caller never wrote (an RLS / sharing / + * tenant read scope). A refusal raised from inside it keeps the #7929 + * redaction: identity (`INVALID_FILTER` / 400) and capability statement on + * the wire, operands in the server log. + * + * There is deliberately no third literal for "unknown": absence of the mark IS + * that state, and it withholds. Declaring it as a value would invite a + * boundary to stamp uncertainty as if it were information. + */ +export type FilterSubtreeProvenance = 'author' | 'policy'; + +/** + * The symbol key the mark lives under on a `FilterCondition` subtree. + * + * Exported for one purpose: so a consumer that must read the mark where the + * helpers cannot be imported (a duplicated package instance) can resolve the + * same key via `Symbol.for`. Everything else goes through + * {@link markFilterSubtreeProvenance} / {@link filterSubtreeProvenanceOf}. + */ +export const FILTER_SUBTREE_PROVENANCE: symbol = Symbol.for( + 'objectstack.filter.subtreeProvenance', +); + +/** + * Stamp `provenance` on one filter subtree, in place, and return it. + * + * The mark is non-enumerable (invisible to `Object.keys`, `JSON.stringify`, + * spread of a rest pattern's enumerable copy, and every schema that walks own + * enumerable keys — a Zod `strictObject` neither sees nor rejects it) and + * non-writable (a later actor cannot flip a policy subtree to `'author'`). + * + * **First mark wins.** A subtree already carrying a valid mark is returned + * unchanged: the actor closest to the subtree's creation is the one that knows + * its provenance, and letting a later, more distant actor overwrite that would + * let a generic boundary re-vouch a policy predicate as the caller's. Marking + * is therefore idempotent, and safe on filter objects that are reused across + * requests (view metadata, cached scopes): the classification of one subtree + * does not change between requests. + * + * Fail-closed by silence: a non-object subtree, a frozen/sealed one, or any + * `defineProperty` refusal leaves the subtree unmarked — and unmarked is + * withheld. No error escapes this function; a boundary must never fail its + * merge because a mark could not be attached. + */ +export function markFilterSubtreeProvenance( + subtree: T, + provenance: FilterSubtreeProvenance, +): T { + if (subtree === null || typeof subtree !== 'object') return subtree; + if (provenance !== 'author' && provenance !== 'policy') return subtree; + if (filterSubtreeProvenanceOf(subtree) !== null) return subtree; + try { + Object.defineProperty(subtree, FILTER_SUBTREE_PROVENANCE, { + value: provenance, + enumerable: false, + writable: false, + configurable: false, + }); + } catch { + // Frozen / sealed / exotic object: stays unmarked, which withholds. + } + return subtree; +} + +/** + * The mark carried by THIS node (own key only — no inheritance), or `null`. + * + * `null` covers every degraded shape by design: no mark, a mark whose value is + * not one of the two declared literals, a non-object. Inheritance from an + * enclosing subtree is position-dependent and belongs to + * {@link resolveFilterSubtreeProvenance}, which knows the root. + */ +export function filterSubtreeProvenanceOf(node: unknown): FilterSubtreeProvenance | null { + if (node === null || (typeof node !== 'object' && typeof node !== 'function')) return null; + const value = (node as Record)[FILTER_SUBTREE_PROVENANCE]; + return value === 'author' || value === 'policy' ? value : null; +} + +/** + * The EFFECTIVE provenance of `target` as it sits inside `root` — the verdict + * a refusal consumer asks for — or `null` when no disclosure-grade answer + * exists. + * + * Effective means positional: the innermost mark on the ancestor chain from + * `root` down to `target` (inclusive of both ends) wins, so a subtree marked + * `'policy'` inside a tree whose root a boundary vouched as `'author'` stays + * policy, and an unmarked comparand inside a marked author arm is the + * author's. `target` is located by object IDENTITY — the reference the refusal + * site held — never by structural equality: two byte-identical predicates with + * different authors are exactly the case this module exists to tell apart. + * + * Answers `null` — which a consumer must treat as `'policy'` (withheld) — for: + * + * - `target` not reachable from `root` (the tree was rewritten between merge + * and refusal, or the refusal site attached a node from a rewritten copy); + * - `target` reachable, but no mark anywhere on any path to it (no boundary + * ever vouched); + * - `target` reachable under MULTIPLE paths whose effective marks disagree + * (one subtree object aliased into both an author arm and a policy arm — + * ambiguous, so withheld). + * + * The walk visits plain objects and arrays only (the shapes + * `FilterConditionSchema` declares), tracks the current path to terminate on a + * cyclic input, and never throws. + */ +export function resolveFilterSubtreeProvenance( + root: unknown, + target: unknown, +): FilterSubtreeProvenance | null { + if (root === null || typeof root !== 'object') return null; + if (target === null || typeof target !== 'object') return null; + + const verdicts = new Set(); + const onPath = new Set(); + + const visit = (node: unknown, inherited: FilterSubtreeProvenance | null): void => { + if (node === null || typeof node !== 'object') return; + if (onPath.has(node)) return; // cyclic input — stop this path + const effective = filterSubtreeProvenanceOf(node) ?? inherited; + if (node === target) { + verdicts.add(effective); + // A node cannot contain itself as a descendant with a DIFFERENT verdict + // unless aliased elsewhere, which the sibling walks below still find. + return; + } + if (!isWalkable(node)) return; + onPath.add(node); + if (Array.isArray(node)) { + for (const element of node) visit(element, effective); + } else { + for (const value of Object.values(node)) visit(value, effective); + } + onPath.delete(node); + }; + + visit(root, null); + + if (verdicts.size !== 1) return null; // unreachable, or conflicting paths + const verdict = verdicts.values().next().value; + return verdict ?? null; +} + +/** + * The shapes the resolver descends into: arrays, and plain objects (prototype + * `Object.prototype` or `null`) — the same node test `filter-verdict.ts` + * applies, for the same reason: a `Date`, `Map` or class instance in a + * comparand position holds no filter subtree, and walking one would read + * semantics into a shape the Filter Protocol does not declare. + */ +function isWalkable(node: object): boolean { + if (Array.isArray(node)) return true; + const proto = Object.getPrototypeOf(node); + return proto === Object.prototype || proto === null; +} diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index c6acc2fcba..f91704d9e4 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -24,6 +24,12 @@ export * from './filter-logic-conformance'; // it: the table is data every backend is checked against, this is the shared // answer three of them now compute WITH. export * from './filter-verdict'; +// [#8220, A of #7929] Filter-subtree provenance — the mark the two read-scope +// merge boundaries stamp on each side of `{ $and: [authorWhere, scope] }`, so +// the SQL family's cross-field refusal can restore the author's full +// diagnostic without re-disclosing policy. Fail direction is CLOSED by +// declaration: unmarked or ambiguous withholds, exactly like `'policy'`. +export * from './filter-subtree-provenance'; // Canonical conformance cases for the filter TEXT operators — case folding // (ASCII-only, #4706 Q1), literal comparands (no LIKE wildcards, no regex // metacharacters), and the refusal of the retired `$regex`/`$options`. A From f4210c60b061dfd78537a8f36c87e6f016256024 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 11:59:35 +0000 Subject: [PATCH 2/3] test(#8220): pin the three-way provenance split at every layer; rewrite both B-era redaction pins deliberately - spec: filter-subtree-provenance unit tests (fail-closed on every degraded shape) - driver-sql / driver-turso: author discloses (eager + lazy seams), policy/ unmarked/ambiguous withhold byte-identically - plugin-security / service-analytics: both boundaries' stamping pinned, including the sibling-rewrite fail-closed vouch - runtime: the byte-equality pin rewritten into its successor (author differs and names columns; unmarked still withholds, byte-identical to policy) - service-analytics engine-fallback: B's blanket-redaction pin on the caller's own where rewritten into the author-restored assertions, with the policy- scope non-disclosure pinned as its fail-closed pair - spec api-surface/export-origins regenerated; changeset added Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AkiE189Csm7aBT2qCVSx3X --- .changeset/read-scope-provenance-mark.md | 68 ++++++ .../sql-driver-cross-field-provenance.test.ts | 204 ++++++++++++++++++ ...e-transport-cross-field-provenance.test.ts | 117 ++++++++++ .../src/read-scope-provenance-mark.test.ts | 184 ++++++++++++++++ ...oss-field-refusal-operand-withhold.test.ts | 145 ++++++++++--- .../cross-field-engine-fallback.test.ts | 37 ++-- .../read-scope-provenance-mark.test.ts | 87 ++++++++ packages/spec/api-surface/data.json | 5 + packages/spec/export-origins/data.json | 5 + 9 files changed, 810 insertions(+), 42 deletions(-) create mode 100644 .changeset/read-scope-provenance-mark.md create mode 100644 packages/drivers/driver-sql/src/sql-driver-cross-field-provenance.test.ts create mode 100644 packages/drivers/driver-turso/src/remote-transport-cross-field-provenance.test.ts create mode 100644 packages/plugins/plugin-security/src/read-scope-provenance-mark.test.ts create mode 100644 packages/services/service-analytics/src/__tests__/read-scope-provenance-mark.test.ts diff --git a/.changeset/read-scope-provenance-mark.md b/.changeset/read-scope-provenance-mark.md new file mode 100644 index 0000000000..d319a22371 --- /dev/null +++ b/.changeset/read-scope-provenance-mark.md @@ -0,0 +1,68 @@ +--- +"@objectstack/spec": minor +"@objectstack/driver-sql": patch +"@objectstack/driver-turso": patch +"@objectstack/plugin-security": patch +"@objectstack/service-analytics": patch +--- + +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) + +#8198 (B of the 2026-08-12 #7929 ruling) made the SQL family's cross-field +`{ $field }` refusal withhold its operands from **every** caller, because the +predicate reached the driver as a bare `FilterCondition`: an administrator's +CEL sharing/permission rule and the author's own filter were indistinguishable +there. The accepted, named cost was the author's diagnostic. This change is A +— the sanctioned follow-up that pays it back behind a real mark instead of a +guess. + +**The mark** (`@objectstack/spec/data`, `filter-subtree-provenance.ts`) is a +spec-declared symbol on a filter subtree: `markFilterSubtreeProvenance(subtree, +'author' | 'policy')`, read positionally by +`resolveFilterSubtreeProvenance(root, node)` (innermost mark on the ancestor +chain wins; located by object identity, never structural equality). It rides +the `where` tree by reference across the `DriverQuery` boundary — no new slot, +documented on `DriverQuery` itself — and is dropped by exactly the operations +(serialize, copy, rewrite) after which no attestation could be trusted. + +**Set at both read-scope merge boundaries**: `plugin-security`'s CRUD RLS +injection marks every injected scope `'policy'` and the caller's verbatim +predicate `'author'` — the latter only under the identity vouch +`ast.where === options.where`, so a tree a sibling middleware already rewrote +is vouched for nobody. `service-analytics`' `ObjectQLStrategy.withReadScope` +marks its scope `'policy'` and the strategy-built user filter `'author'` (and +`resolveFkAttr`'s scope arm `'policy'`). + +**Consumed by the SQL family** (`driver-sql`, `driver-turso`'s +`RemoteTransport`; `driver-sqlite-wasm` inherits): a refusal raised from a +subtree positively marked `'author'` carries its full diagnostic on the wire +again — both columns, the operator, the list index, the boundary reason — +same identity (`INVALID_FILTER` / 400). + +**⚠️ The fail direction is closed, and it is the design**: unmarked or +ambiguous — no mark anywhere, a mark lost to serialization, a node +unreachable from the query's own `where`, conflicting aliased marks — +withholds exactly like `'policy'`. The mark is permission to reveal, never a +requirement to prove secrecy; a driver-side guess at provenance is the shape +the #7929 triage rejected. + +**Two B-era pins were REWRITTEN deliberately, not weakened.** First, +`service-analytics`' `cross-field-engine-fallback.test.ts` pinned B's blanket +redaction on refusals of the caller's OWN `where` (no scope in play) — under A +that caller is the vouched author, so those cases now assert the corpus's +`diagnosticIncludes` fragments are back on the wire, while the +policy-injected-scope case gains the explicit non-disclosure assertions as its +fail-closed pair. Second, the sharper one: +`packages/runtime/src/cross-field-refusal-operand-withhold.test.ts` pinned +author-written and policy-injected refusals **byte-identical** — the strongest +available statement of "the driver cannot tell them apart", and explicitly the +assertion A was chartered to supersede. Its successor pins the three-way split +#8220's "Done means" names: policy-injected withholds (unchanged), the vouched +author's filter names its columns again (the messages now differ, by design), +and an unmarked predicate still withholds **byte-identical to the policy +case** — B's surviving half. Reading that diff as a regression is exactly what +the old pin's comment warned against; the file header carries the full +account. + +Unaffected: the REST boundary's 5xx-only withhold (#5367/#5667) and every +refusal outside the cross-field family. diff --git a/packages/drivers/driver-sql/src/sql-driver-cross-field-provenance.test.ts b/packages/drivers/driver-sql/src/sql-driver-cross-field-provenance.test.ts new file mode 100644 index 0000000000..7dbb1202e9 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-cross-field-provenance.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8220, A of the #7929 ruling] The provenance-aware half of the cross-field + * withhold: a subtree positively marked `'author'` gets its full diagnostic + * back on the wire; `'policy'`, unmarked and ambiguous all keep the #7929 + * redaction. This file pins the CONSUMER — the two seams in `SqlDriver` that + * resolve a refusal against the query's own `where` root — on a real driver. + * + * The three-way split matters more than either end of it: + * + * - `author ≠ policy` is the capability this card adds (B pinned them + * byte-identical, deliberately, until the mark existed); + * - `unmarked = policy` is the FAIL DIRECTION — the mark is permission to + * reveal, and a missing mark must never land on the disclosing branch. The + * byte-equality that used to pin author-vs-policy now pins + * unmarked-vs-policy, which is the half of B that survives A. + * + * The two merge boundaries' own stamping is pinned in their own packages + * (plugin-security, service-analytics) and end-to-end in + * `packages/runtime/src/cross-field-refusal-operand-withhold.test.ts`. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { SqlDriver } from './index.js'; +import { + markFilterSubtreeProvenance, + type FilterCondition, +} from '@objectstack/spec/data'; + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +describe('[#8220] cross-field refusal × filter-subtree provenance', () => { + let driver: SqlDriver; + let logged: string[]; + + beforeEach(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.initObjects([ + { + name: 'deal', + fields: { + id: { type: 'text', name: 'id' }, + stage: { type: 'text', name: 'stage' }, + amount: { type: 'number', name: 'amount' }, + budget: { type: 'number', name: 'budget' }, + organization_id: { type: 'text', name: 'organization_id' }, + }, + } as any, + ]); + await driver.create('deal', { + id: '1', stage: 'won', amount: 10, budget: 5, organization_id: 'o1', + }); + logged = []; + (driver as unknown as { logger: unknown }).logger = { + warn: (m: string) => { logged.push(String(m)); }, + error: () => {}, + info: () => {}, + debug: () => {}, + }; + }); + + const refusalOf = async (where: unknown): Promise => { + try { + await driver.find('deal', { fields: ['id'], where: where as FilterCondition }); + } catch (e) { + return e as WireBearingError; + } + throw new Error('expected the driver to refuse this filter, but it resolved'); + }; + + // The one predicate every case below spells: a cross-field reference at an + // operator whose position SQL push-down does not compile ($contains takes a + // literal). Column names chosen to read as what each case casts them as. + const uncompilable = () => ({ stage: { $contains: { $field: 'organization_id' } } }); + + it("an 'author'-marked filter gets its operands back — eager, top-level position", async () => { + const where = markFilterSubtreeProvenance(uncompilable(), 'author'); + const err = await refusalOf(where); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain('stage'); + expect(err.message).toContain('organization_id'); + expect(err.message).toContain('$contains'); + // Restored on the wire ⇒ nothing left to relocate to the log. + expect(logged.join('\n')).toBe(''); + }); + + it("an 'author'-marked arm inside a merged $and discloses too — the LAZY group-callback position", async () => { + // The exact shape a merge boundary produces. The refusal here MUST be one + // only the EMITTER raises (an undeclared referenced column at a scalar + // operator — the walk skips the six), because the emitter for a nested arm + // runs inside a knex group callback, LAZILY, past `applyFilters`' catch: + // this is the seam `withWithheldFilterLog` threads the root for. + const authorArm = markFilterSubtreeProvenance( + { amount: { $gt: { $field: 'no_such_column' } } }, + 'author', + ); + const where = { $and: [{ amount: { $gt: { $field: 'budget' } } }, authorArm] }; + const err = await refusalOf(where); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.message).toContain('no_such_column'); + expect(err.message).toContain('not a declared field'); + }); + + it("a 'policy'-marked filter keeps the redaction, and the diagnostic reaches the log", async () => { + const where = markFilterSubtreeProvenance(uncompilable(), 'policy'); + const err = await refusalOf(where); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).not.toContain('stage'); + expect(err.message).not.toContain('organization_id'); + expect(logged.join('\n')).toContain('organization_id'); + }); + + it('an UNMARKED filter withholds byte-identically to a policy-marked one — the fail direction', async () => { + const unmarked = await refusalOf(uncompilable()); + logged = []; + const policy = await refusalOf(markFilterSubtreeProvenance(uncompilable(), 'policy')); + expect(unmarked.message).toBe(policy.message); + expect(unmarked.code).toBe('INVALID_FILTER'); + expect(unmarked.message).not.toContain('organization_id'); + }); + + it('a serialization round-trip DROPS an author mark — the copy withholds', async () => { + const marked = markFilterSubtreeProvenance(uncompilable(), 'author'); + const err = await refusalOf(JSON.parse(JSON.stringify(marked))); + expect(err.message).not.toContain('organization_id'); + expect(logged.join('\n')).toContain('organization_id'); + }); + + it("the innermost mark wins: 'policy' nested under an 'author' root stays redacted", async () => { + const scope = markFilterSubtreeProvenance(uncompilable(), 'policy'); + const where = markFilterSubtreeProvenance( + { $and: [{ amount: { $gt: 1 } }, scope] }, + 'author', + ); + const err = await refusalOf(where); + expect(err.message).not.toContain('organization_id'); + expect(logged.join('\n')).toContain('organization_id'); + }); + + it('a subtree aliased under CONFLICTING marks is ambiguous and withholds', async () => { + const shared = uncompilable(); + const where = { + $or: [ + markFilterSubtreeProvenance({ $and: [shared] }, 'author'), + markFilterSubtreeProvenance({ $and: [shared] }, 'policy'), + ], + }; + const err = await refusalOf(where); + expect(err.message).not.toContain('organization_id'); + }); + + it('the boundary-reason family (uncompilableFieldReferenceError) discloses its reason for an author', async () => { + // An undeclared referenced column at a COMPILABLE operator — the #5222 + // validation-boundary arm, raised inside the cross-field emitter. + const where = markFilterSubtreeProvenance( + { amount: { $gt: { $field: 'no_such_column' } } }, + 'author', + ); + const err = await refusalOf(where); + expect(err.message).toContain('no_such_column'); + expect(err.message).toContain('not a declared field'); + // …and the same spelling unmarked stays generic. + logged = []; + const withheld = await refusalOf({ amount: { $gt: { $field: 'no_such_column' } } }); + expect(withheld.message).not.toContain('no_such_column'); + expect(logged.join('\n')).toContain('no_such_column'); + }); + + it('a list-member reference names its index for an author', async () => { + const where = markFilterSubtreeProvenance( + { amount: { $in: [1, { $field: 'budget' }] } }, + 'author', + ); + const err = await refusalOf(where); + expect(err.message).toContain('budget'); + expect(err.message).toContain('index 1'); + }); + + it('a bare `$field` spec gets its corrected spelling back for an author', async () => { + const where = markFilterSubtreeProvenance({ amount: { $field: 'budget' } }, 'author'); + const err = await refusalOf(where); + expect(err.message).toContain('{ "amount": { "$eq": { "$field": "budget" } } }'); + }); + + it("the tenant-column arm still discloses to a positively marked author — they typed the column", async () => { + const where = markFilterSubtreeProvenance( + { stage: { $eq: { $field: 'organization_id' } } }, + 'author', + ); + const err = await refusalOf(where); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.message).toContain('organization_id'); + }); +}); diff --git a/packages/drivers/driver-turso/src/remote-transport-cross-field-provenance.test.ts b/packages/drivers/driver-turso/src/remote-transport-cross-field-provenance.test.ts new file mode 100644 index 0000000000..b601b23467 --- /dev/null +++ b/packages/drivers/driver-turso/src/remote-transport-cross-field-provenance.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8220, A of the #7929 ruling] The remote transport's half of the + * provenance-aware withhold. `RemoteTransport` carries its OWN copy of the + * cross-field refusal (`uncompilableComparand` — no executor compiles the + * form in remote mode at all), so it consumes the spec's filter-subtree mark + * itself, at its `buildWhereSQL` entry: an `'author'`-marked subtree gets the + * operand-naming text back; `'policy'`, unmarked and a mark lost to a + * serialization round-trip all keep the #7929 redaction. Leaving one mode + * disclosing would make the disclosure a property of the connection string — + * the same reason #8198 fixed both compilers at once. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RemoteTransport } from './remote-transport.js'; +import { markFilterSubtreeProvenance } from '@objectstack/spec/data'; + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +function transport() { + const client = { + execute: vi.fn(async () => ({ rows: [], columns: [] })), + close: vi.fn(), + }; + const sink: string[] = []; + const t = new RemoteTransport(); + t.setClient(client as any); + t.setDiagnosticSink((m) => sink.push(m)); + return { t, sink }; +} + +const refusalOf = async (run: () => Promise): Promise => { + try { + await run(); + } catch (e) { + return e as WireBearingError; + } + throw new Error('expected the transport to refuse this filter, but it resolved'); +}; + +describe('[#8220] RemoteTransport cross-field refusal × filter-subtree provenance', () => { + const crossField = () => ({ amount: { $gt: { $field: 'budget' } } }); + + it("an 'author'-marked filter gets its operands back", async () => { + const { t } = transport(); + const err = await refusalOf(() => + t.find('deal', { where: markFilterSubtreeProvenance(crossField(), 'author') }), + ); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain('deal.amount'); + expect(err.message).toContain('budget'); + }); + + it("an 'author'-marked arm inside a merged $and discloses too", async () => { + const { t } = transport(); + const authorArm = markFilterSubtreeProvenance(crossField(), 'author'); + const err = await refusalOf(() => + t.find('deal', { where: { $and: [{ stage: 'won' }, authorArm] } }), + ); + expect(err.message).toContain('budget'); + }); + + it("a 'policy'-marked filter keeps the redaction; the diagnostic reaches the sink", async () => { + const { t, sink } = transport(); + const err = await refusalOf(() => + t.find('deal', { where: markFilterSubtreeProvenance(crossField(), 'policy') }), + ); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.message).not.toContain('budget'); + expect(err.message).not.toContain('amount'); + expect(sink.join('\n')).toContain('budget'); + }); + + it('an UNMARKED filter withholds byte-identically to a policy-marked one — the fail direction', async () => { + const { t } = transport(); + const unmarked = await refusalOf(() => t.find('deal', { where: crossField() })); + const policy = await refusalOf(() => + t.find('deal', { where: markFilterSubtreeProvenance(crossField(), 'policy') }), + ); + expect(unmarked.message).toBe(policy.message); + expect(unmarked.message).not.toContain('budget'); + }); + + it('a serialization round-trip DROPS an author mark — the copy withholds', async () => { + const { t } = transport(); + const marked = markFilterSubtreeProvenance(crossField(), 'author'); + const err = await refusalOf(() => + t.find('deal', { where: JSON.parse(JSON.stringify(marked)) }), + ); + expect(err.message).not.toContain('budget'); + }); + + it("the innermost mark wins: 'policy' nested under an 'author' root stays redacted", async () => { + const { t } = transport(); + const scope = markFilterSubtreeProvenance(crossField(), 'policy'); + const where = markFilterSubtreeProvenance({ $and: [{ stage: 'won' }, scope] }, 'author'); + const err = await refusalOf(() => t.find('deal', { where })); + expect(err.message).not.toContain('budget'); + }); + + it('the non-$field comparand refusal is untouched — it never withheld, it never discloses more', async () => { + // A Uint8Array comparand is refused by NAME with no redaction (#1058); + // provenance resolution must leave errors that never carried the withheld + // symbol exactly alone. + const { t } = transport(); + const err = await refusalOf(() => + t.find('deal', { where: markFilterSubtreeProvenance({ amount: { $gt: new Uint8Array([1]) } } as never, 'author') }), + ); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.message).toContain('deal.amount'); + }); +}); diff --git a/packages/plugins/plugin-security/src/read-scope-provenance-mark.test.ts b/packages/plugins/plugin-security/src/read-scope-provenance-mark.test.ts new file mode 100644 index 0000000000..dc4bd8ad49 --- /dev/null +++ b/packages/plugins/plugin-security/src/read-scope-provenance-mark.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8220, A of the #7929 ruling] The CRUD merge boundary's half of the + * filter-subtree provenance mark: this middleware is the frame that knows + * which subtree the caller did not write, so it is the frame that stamps it. + * + * What is pinned, and why each half matters: + * + * - every injected read scope (RLS, controlled-by-parent, the fail-closed + * deny sentinel) is marked `'policy'` — that is what keeps the driver's + * cross-field refusal redacted for predicates an administrator authored; + * - the caller's own predicate is marked `'author'` ONLY under the identity + * vouch `opCtx.ast.where === opCtx.options.where` — the caller's verbatim + * predicate, untouched by any sibling middleware. A tree a sibling already + * rewrote gets NO mark, and unmarked withholds: the mark is permission to + * reveal, never a guess (the fail-closed invariant this card is chartered + * on). + * + * The driver-side consumption is pinned in `driver-sql` + * (`sql-driver-cross-field-provenance.test.ts`) and end-to-end in + * `packages/runtime/src/cross-field-refusal-operand-withhold.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { filterSubtreeProvenanceOf, resolveFilterSubtreeProvenance } from '@objectstack/spec/data'; + +import { SecurityPlugin } from './security-plugin.js'; + +const tenantPolicySet: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + rowLevelSecurity: [ + { name: 'tenant_isolation', object: '*', operation: 'all', using: 'organization_id = current_user.organization_id' }, + ], +} as never; + +/** The minimal middleware harness `security-plugin.test.ts` boots with. */ +const makeHarness = (overrides?: { permissionSets?: PermissionSet[]; orgScoping?: boolean }) => { + const fields: Record = {}; + for (const f of ['id', 'organization_id', 'owner_id', 'name', 'amount', 'budget']) { + fields[f] = { name: f }; + } + const baseSchema = { name: 'task', fields }; + let middleware: ((opCtx: unknown, next: () => Promise) => Promise) | undefined; + const ql = { + registerMiddleware: (mw: never) => { + if (!middleware) middleware = mw; + }, + getSchema: () => baseSchema, + findOne: vi.fn(async () => null), + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + metadata: { get: async () => baseSchema, list: async () => overrides?.permissionSets ?? [tenantPolicySet] }, + }; + if (overrides?.orgScoping !== false) { + services['org-scoping'] = { name: 'com.objectstack.org-scoping' }; + } + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + return { + ctx, + run: async (opCtx: unknown) => { + if (!middleware) throw new Error('middleware never registered'); + await middleware(opCtx, async () => {}); + return opCtx; + }, + }; +}; + +const memberContext = () => ({ + userId: 'u1', + tenantId: 'org-1', + positions: [], + permissions: [], +}); + +const boot = async (overrides?: Parameters[0]) => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeHarness(overrides); + await plugin.init(harness.ctx as never); + await plugin.start(harness.ctx as never); + return harness; +}; + +describe('[#8220] the CRUD read-scope merge boundary stamps filter-subtree provenance', () => { + it("marks every injected scope 'policy' and the caller's verbatim where 'author'", async () => { + const harness = await boot(); + const callerWhere = { amount: { $gt: { $field: 'budget' } } }; + const opCtx: Record = { + object: 'task', + operation: 'find', + ast: { object: 'task', where: callerWhere }, + options: { where: callerWhere }, + context: memberContext(), + }; + await harness.run(opCtx); + + // The merge really happened — caller's arm first, scopes after. + expect(opCtx.ast.where.$and[0]).toBe(callerWhere); + expect(filterSubtreeProvenanceOf(callerWhere)).toBe('author'); + const scopes = opCtx.ast.where.$and.slice(1); + expect(scopes.length).toBeGreaterThan(0); + for (const scope of scopes) { + expect(filterSubtreeProvenanceOf(scope)).toBe('policy'); + } + // The mark is invisible to enumeration — nothing downstream serialises it. + expect(JSON.stringify(callerWhere)).toBe('{"amount":{"$gt":{"$field":"budget"}}}'); + }); + + it("a caller with NO where still gets the scope marked 'policy' (nothing vouched author)", async () => { + const harness = await boot(); + const opCtx: Record = { + object: 'task', + operation: 'find', + ast: { object: 'task', where: undefined }, + options: {}, + context: memberContext(), + }; + await harness.run(opCtx); + expect(opCtx.ast.where).toBeTruthy(); + // With nothing to merge against, the injected scope IS the whole where — + // the mark sits on that root, and every inner arm inherits it positionally + // (`resolveFilterSubtreeProvenance`'s innermost-wins walk). + expect(filterSubtreeProvenanceOf(opCtx.ast.where)).toBe('policy'); + const innerArm = opCtx.ast.where.$and?.[0]; + if (innerArm) { + expect(resolveFilterSubtreeProvenance(opCtx.ast.where, innerArm)).toBe('policy'); + } + }); + + it('⚠️ fail closed: a where a SIBLING already rewrote gets NO author mark', async () => { + const harness = await boot(); + const callerWhere = { amount: { $gt: { $field: 'budget' } } }; + // plugin-sharing's shape: the sibling composed its own filter in first, so + // ast.where is no longer the caller's verbatim object. + const rewritten = { $and: [callerWhere, { owner_id: 'u1' }] }; + const opCtx: Record = { + object: 'task', + operation: 'find', + ast: { object: 'task', where: rewritten }, + options: { where: callerWhere }, + context: memberContext(), + }; + await harness.run(opCtx); + // The rewritten root was NOT vouched — the boundary cannot know which of + // its arms the caller wrote. The caller subtree keeps no mark either. + expect(filterSubtreeProvenanceOf(rewritten)).toBe(null); + expect(filterSubtreeProvenanceOf(callerWhere)).toBe(null); + // The injected scopes are still marked policy. + const arms = opCtx.ast.where.$and ?? []; + const marked = arms.filter((a: unknown) => filterSubtreeProvenanceOf(a) === 'policy'); + expect(marked.length).toBeGreaterThan(0); + }); + + it("the fail-closed RLS deny sentinel is marked 'policy' too", async () => { + // No org in context → the tenant policy's token cannot resolve → the + // compiler answers the deny sentinel. It is as policy-authored as any + // resolvable scope, and its shape must stay unnamed the same way. + const harness = await boot(); + const opCtx: Record = { + object: 'task', + operation: 'find', + ast: { object: 'task', where: undefined }, + options: {}, + context: { userId: 'u1', positions: [], permissions: [] }, + }; + await harness.run(opCtx); + expect(opCtx.ast.where).toBeTruthy(); + // Same shape as the no-where case above: the deny sentinel (or the + // composite carrying it) IS the where, marked at its root. + expect(filterSubtreeProvenanceOf(opCtx.ast.where)).toBe('policy'); + }); +}); diff --git a/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts b/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts index 235c0696c6..bd469034f7 100644 --- a/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts +++ b/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts @@ -43,17 +43,32 @@ * through a real `ObjectQL` engine with a security-middleware-shaped injection * for the CRUD face. * - * ## The author's case is here on purpose + * ## [#8220] The byte-equality pin was REWRITTEN here, deliberately * - * B withholds for EVERY caller, because the driver cannot tell an author's - * filter from a policy's — `DriverQuery` carries no provenance and the two - * messages were byte-identical before this change. So an author debugging their - * own cross-field filter now gets the redacted message too. That is a real - * diagnostic regression, ruled an accepted cost until #7929's follow-up (A: a - * spec-declared provenance mark set at both boundaries) restores the - * author-facing text behind a real mark. It is pinned below rather than left - * implicit, so that "the author still sees the columns" cannot be restored by - * accident — it would reopen the disclosure on every unmarked policy predicate. + * Until A landed, section (c) pinned a byte-EQUALITY: an author-written + * `$field` filter and a policy-injected one had to produce IDENTICAL messages, + * because the driver genuinely could not tell them apart — `DriverQuery` + * carried no provenance, and pinning the equality was the strongest available + * statement of that fact. A (#8220, the sanctioned follow-up of the same + * ruling) is precisely the change that makes them distinguishable: the two + * merge boundaries stamp the spec-declared filter-subtree provenance mark + * (`markFilterSubtreeProvenance`, `@objectstack/spec/data`) on each arm of + * their merge, and the driver restores the full author-facing diagnostic for + * a subtree POSITIVELY marked `'author'`. + * + * So the old equality is not a failing test to re-green — it is SUPERSEDED, + * and section (c) now pins its successor, the three-way split "Done means" on + * #8220 names: + * + * 1. a policy-injected predicate still withholds (unchanged from B); + * 2. an author's own filter, vouched at a real merge boundary, names its + * columns again — the messages now DIFFER, by design; + * 3. an UNMARKED predicate — one no boundary ever vouched — still withholds, + * byte-identical to the policy case. This is B's surviving half, and the + * fail-closed invariant: the mark is permission to reveal, so "mark + * missing" must never land on the disclosing branch. ⛔ Do not "restore + * the author's columns" for this case; that reopens the disclosure on + * every unmarked policy predicate. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -63,6 +78,7 @@ import { CROSS_FIELD_OBJECT_FIELDS, CROSS_FIELD_ROWS } from '@objectstack/driver import { AnalyticsService } from '@objectstack/service-analytics'; import type { AnalyticsQuery, DriverQuery } from '@objectstack/spec/contracts'; import type { AggregationNode, Cube, FilterCondition } from '@objectstack/spec/data'; +import { markFilterSubtreeProvenance } from '@objectstack/spec/data'; import { createDispatcherPlugin } from './dispatcher-plugin.js'; @@ -280,6 +296,29 @@ describe('[#7929] a cross-field refusal keeps its envelope and stops disclosing expect(String(res.body?.error?.message)).not.toContain('secret_policy_column'); expect(rawSqlCalls).toEqual([]); }); + + it("[#8220] an AUTHOR-written `where` on this face names its columns again", async () => { + // No read scope in play: the strategy-built filter is the caller's own + // text, `withReadScope` marks it 'author', and the driver restores the + // full diagnostic through the same real route the withhold cases use. + logged = []; + rawSqlCalls = []; + readScope = null; + const res = await postAnalyticsQuery(analytics, { + cube: 'deals', + dimensions: ['id'], + measures: ['n'], + where: { amount: { $gt: { $field: 'secret_policy_column' } } }, + } as unknown as AnalyticsQuery); + + expect(res.body?.success).toBe(false); + expect(res.body?.error?.code).toBe('INVALID_FILTER'); + expect(res.body?.error?.httpStatus ?? res.statusCode).toBe(400); + expect(rawSqlCalls, 'NativeSQLStrategy did not decline').toEqual([]); + const message = String(res.body?.error?.message ?? ''); + expect(message).toContain('amount'); + expect(message).toContain('secret_policy_column'); + }); }); // ── (b) the CRUD face — #7988's measurement ────────────────────────────── @@ -310,12 +349,21 @@ describe('[#7929] a cross-field refusal keeps its envelope and stops disclosing // (`security-plugin.ts`: `ast.where = ast.where ? { $and: [ast.where, // …extra] } : extra[0]`), because the claim under test is about what // THAT produces — an admin predicate the caller never wrote, in the same - // `where` as the caller's own. + // `where` as the caller's own. [#8220] Including its marking half: the + // scope is stamped 'policy', and the caller's where is stamped 'author' + // under the same identity vouch the real boundary applies + // (`ast.where === options.where` — the caller's verbatim predicate). ql.registerMiddleware(async (ctx: any, next: () => Promise) => { - if (['find', 'findOne', 'count', 'aggregate'].includes(ctx.operation) && crudScope) { + if (['find', 'findOne', 'count', 'aggregate'].includes(ctx.operation)) { const ast: any = ctx.ast ?? { object: ctx.object }; - ast.where = ast.where ? { $and: [ast.where, crudScope] } : crudScope; - ctx.ast = ast; + if (ast.where && ast.where === ctx.options?.where) { + markFilterSubtreeProvenance(ast.where, 'author'); + } + if (crudScope) { + markFilterSubtreeProvenance(crudScope, 'policy'); + ast.where = ast.where ? { $and: [ast.where, crudScope] } : crudScope; + ctx.ast = ast; + } } await next(); }); @@ -367,14 +415,34 @@ describe('[#7929] a cross-field refusal keeps its envelope and stops disclosing expect(log).toContain('organization_id'); }); - // ── (c) the honest author pays the same price, deliberately ──────────── + // ── (c) [#8220] the successor of B's byte-equality pin ───────────────── + // + // The old pin here asserted author-written === policy-injected, byte for + // byte, as the strongest statement of "the driver cannot tell them apart". + // A is the card that made them distinguishable — behind the spec-declared + // provenance mark, set by the boundary above — so the pin is REWRITTEN + // (not deleted, not weakened) into the three-way split #8220's "Done + // means" names. See the file header for the full account. + + it("an AUTHOR-written `$field` filter — vouched by the boundary — names its columns again", async () => { + // Same predicate as the policy case below, authored by the caller: the + // middleware marks it 'author' under the identity vouch, and the driver + // restores the full diagnostic — both columns, the operator, the + // boundary reason. + const authorWritten = await readWithScope( + null, + { amount: { $gt: { $field: 'secret_policy_column' } } } as FilterCondition, + ); + expect(authorWritten.err.code).toBe('INVALID_FILTER'); + expect(authorWritten.err.status).toBe(400); + expect(authorWritten.err.message).toContain('amount'); + expect(authorWritten.err.message).toContain('secret_policy_column'); + expect(authorWritten.err.message).toContain('not a declared field'); + // Disclosed on the wire ⇒ nothing left to relocate to the server log. + expect(authorWritten.logged).not.toContain('secret_policy_column'); + }); - it('an AUTHOR-written `$field` filter gets the identical redacted message', async () => { - // The accepted cost of B, pinned as an equality rather than described. - // Byte-identical is the strongest available statement of "the driver - // cannot tell these apart", and it is also the regression guard for A: - // when the provenance mark lands, THIS assertion is the one that must be - // rewritten deliberately, in the card that restores the author's text. + it('…and the messages now DIFFER from the policy-injected case, by design', async () => { const policyAuthored = await readWithScope( { amount: { $gt: { $field: 'secret_policy_column' } } } as FilterCondition, ); @@ -382,12 +450,35 @@ describe('[#7929] a cross-field refusal keeps its envelope and stops disclosing null, { amount: { $gt: { $field: 'secret_policy_column' } } } as FilterCondition, ); - expect(authorWritten.err.code).toBe('INVALID_FILTER'); - expect(authorWritten.err.status).toBe(400); - expect(authorWritten.err.message).toBe(policyAuthored.err.message); - // The author's own diagnostic is not destroyed — it is relocated to the - // server log, which is where an operator can still answer their ticket. - expect(authorWritten.logged).toContain('secret_policy_column'); + expect(authorWritten.err.message).not.toBe(policyAuthored.err.message); + // The policy case is still B's: redacted on the wire, full text in the log. + expect(policyAuthored.err.message).not.toContain('secret_policy_column'); + expect(policyAuthored.logged).toContain('secret_policy_column'); + }); + + it('⚠️ an UNMARKED predicate still withholds, byte-identical to the policy case', async () => { + // The fail-closed invariant, pinned as an equality exactly the way B's + // pin was: a predicate NO boundary vouched — here, one handed straight + // to the driver, the shape every other entry into the driver has — gets + // the same redacted message as an admin's read scope. The mark is + // permission to reveal; its absence must never disclose. + const policyAuthored = await readWithScope( + { amount: { $gt: { $field: 'secret_policy_column' } } } as FilterCondition, + ); + let unmarkedErr: WireBearingError | null = null; + try { + await driver.find(OBJECT, { + where: { amount: { $gt: { $field: 'secret_policy_column' } } } as FilterCondition, + } as DriverQuery as never); + } catch (e) { + unmarkedErr = e as WireBearingError; + } + if (!unmarkedErr) throw new Error('expected the unmarked read to be refused'); + expect(unmarkedErr.code).toBe('INVALID_FILTER'); + expect(unmarkedErr.message).toBe(policyAuthored.err.message); + for (const policyName of ['secret_policy_column', 'amount']) { + expect(unmarkedErr.message, `the refusal names \"${policyName}\"`).not.toContain(policyName); + } }); }); }); diff --git a/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts b/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts index 070608d93e..567917bdd4 100644 --- a/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts +++ b/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts @@ -283,23 +283,24 @@ describe('[#7598] cross-field `$field` on the analytics face — served via the // Never a bind-layer accident dressed up as a refusal. expect(err).not.toBeInstanceOf(TypeError); expect(err.message).not.toContain('can only bind'); - // [#7929, maintainer ruling 2026-08-12 — B] The ROUTED half is the one - // the ruling changed: those refusals are `driver-sql`'s, and the driver - // no longer echoes either operand, because on a read-scope query both - // columns were written by an administrator the caller never saw. This - // is the analytics face of that pin — the four captured response bodies - // on #7929 all came down this road. + // [#7929 — B, REWRITTEN by #8220 — A] These cases drive the CALLER's + // own `where` with NO read scope in play, so under A they are the + // AUTHOR's: `withReadScope` marks the strategy-built user filter + // 'author', and the routed driver refusal names the operands again — + // the corpus's `diagnosticIncludes` substrings are back ON THE WIRE + // for exactly this caller. B's blanket redaction pin here is + // superseded the same way the runtime byte-equality pin was + // (`cross-field-refusal-operand-withhold.test.ts` carries the full + // account). The withhold itself did not move: a POLICY-injected scope + // stays redacted — pinned just below, on this same road. // - // ⛔ The `routed === false` half is deliberately NOT asserted the same - // way. Those refusals never reach a driver: this package answers them - // itself (`fieldReferenceBetweenBoundMessage` and the `$in`/LIKE arms), - // with wording that still names both operands. That is a services-lane - // surface and B is scoped to the driver, so the gap is recorded rather - // than closed here — asserting it green would be asserting something - // this change did not do. + // ⛔ The `routed === false` half is still deliberately NOT asserted + // for disclosure the driver way. Those refusals never reach a driver: + // this package answers them itself with wording that always named + // both operands, before B and after A alike. if (routed) { - for (const column of CROSS_FIELD_OPERAND_NAMES) { - expect(err.message, `driver refusal names "${column}"${note}`).not.toContain(column); + for (const fragment of refusal.diagnosticIncludes) { + expect(err.message, `author-restored refusal lost "${fragment}"${note}`).toContain(fragment); } } // The native-SQL emitter never saw it either way — declined, or refused @@ -317,6 +318,12 @@ describe('[#7598] cross-field `$field` on the analytics face — served via the const err = await errorFrom(() => idsFor({})); expect(err.code).toBe('INVALID_FILTER'); expect(err.status).toBe(400); + // [#8220] …and it stays REDACTED: the scope is marked 'policy' at + // `withReadScope`, so A's author-restore above must not leak here. The + // fail-closed pair of the disclosure assertions in the loop. + for (const column of CROSS_FIELD_OPERAND_NAMES) { + expect(err.message, `policy refusal names "${column}"`).not.toContain(column); + } expect(rawSqlCalls).toEqual([]); readScope = null; }); diff --git a/packages/services/service-analytics/src/__tests__/read-scope-provenance-mark.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-provenance-mark.test.ts new file mode 100644 index 0000000000..58df6c1f71 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/read-scope-provenance-mark.test.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8220, A of the #7929 ruling] The analytics merge boundary's half of the + * filter-subtree provenance mark. `ObjectQLStrategy.withReadScope` is the + * second of the two boundaries (the first is plugin-security's CRUD + * injection): it composes `{ $and: [userFilter, scope] }` and is the only + * frame that knows which arm the caller wrote — so it stamps both. + * + * - the read scope → `'policy'`: the driver's cross-field refusal stays + * redacted for it; + * - the strategy-built user filter → `'author'`: every name in it came from + * the caller's own query (dimensions, measures, `where`, time windows) + * through this class's own compilation; + * - the FK-expand's internal `idFilter` (resolveFkAttr) stays UNMARKED on + * purpose — no author typed it, and unmarked withholds (the fail-closed + * invariant). + * + * End-to-end through the real REST route and a real driver: + * `packages/runtime/src/cross-field-refusal-operand-withhold.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { filterSubtreeProvenanceOf } from '@objectstack/spec/data'; + +import { ObjectQLStrategy } from '../strategies/objectql-strategy'; + +/** Reach the private merge exactly as `execute()` calls it. */ +const withReadScope = ( + filter: Record, + getReadScope: unknown, +): Record | undefined => + (new ObjectQLStrategy() as never as { + withReadScope: ( + objectName: string, + filter: Record, + ctx: unknown, + ) => Record | undefined; + }).withReadScope('deal', filter, { getReadScope }); + +describe('[#8220] ObjectQLStrategy.withReadScope stamps filter-subtree provenance', () => { + it("marks the user filter 'author' and the scope 'policy' on the composed $and", () => { + const userFilter = { stage: 'won' }; + const scope = { amount: { $gt: { $field: 'secret_policy_column' } } }; + const merged = withReadScope(userFilter, () => scope)!; + + expect(merged.$and).toEqual([userFilter, scope]); + expect((merged.$and as unknown[])[0]).toBe(userFilter); + expect((merged.$and as unknown[])[1]).toBe(scope); + expect(filterSubtreeProvenanceOf(userFilter)).toBe('author'); + expect(filterSubtreeProvenanceOf(scope)).toBe('policy'); + // The $and root itself carries no mark — provenance is per-arm. + expect(filterSubtreeProvenanceOf(merged)).toBe(null); + // …and the marks are invisible to serialization (nothing rides the wire). + expect(JSON.stringify(merged)).toBe( + '{"$and":[{"stage":"won"},{"amount":{"$gt":{"$field":"secret_policy_column"}}}]}', + ); + }); + + it("a scope with no user filter is returned alone, marked 'policy'", () => { + const scope = { organization_id: 'org-1' }; + const merged = withReadScope({}, () => scope); + expect(merged).toBe(scope); + expect(filterSubtreeProvenanceOf(scope)).toBe('policy'); + }); + + it("a user filter with no scope is returned alone, marked 'author'", () => { + const userFilter = { amount: { $gt: { $field: 'budget' } } }; + expect(withReadScope(userFilter, () => null)).toBe(userFilter); + expect(filterSubtreeProvenanceOf(userFilter)).toBe('author'); + const second = { stage: 'won' }; + expect(withReadScope(second, undefined)).toBe(second); + expect(filterSubtreeProvenanceOf(second)).toBe('author'); + }); + + it('an empty user filter stays undefined — nothing is marked into existence', () => { + expect(withReadScope({}, undefined)).toBeUndefined(); + expect(withReadScope({}, () => undefined)).toBeUndefined(); + }); + + it('a scope already marked keeps its first mark (idempotent across strategies)', () => { + const scope = { organization_id: 'org-1' }; + withReadScope({}, () => scope); + withReadScope({}, () => scope); + expect(filterSubtreeProvenanceOf(scope)).toBe('policy'); + }); +}); diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index bbd1770914..ea17fc429c 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -243,6 +243,7 @@ "FILTER_LOGIC_CASES (const)", "FILTER_LOGIC_ROWS (const)", "FILTER_OPERATORS (const)", + "FILTER_SUBTREE_PROVENANCE (const)", "FILTER_TEXT_CASES (const)", "FILTER_TEXT_ROWS (const)", "FILTER_TOKEN_WRAPPED_RE (const)", @@ -283,6 +284,7 @@ "FilterLogicCase (interface)", "FilterLogicRow (interface)", "FilterOperatorKey (type)", + "FilterSubtreeProvenance (type)", "FilterTextCase (type)", "FilterTextRejectionCase (interface)", "FilterTextRow (interface)", @@ -633,6 +635,7 @@ "effectiveOperationsArray (function)", "emptyGroupValueFor (function)", "fieldForm (const)", + "filterSubtreeProvenanceOf (function)", "foldAsciiCase (function)", "foldQueryAliasSlots (function)", "formatUnknownAuthoringKey (function)", @@ -677,6 +680,7 @@ "likePatternToGlobPattern (function)", "likePatternToRegexSource (function)", "lintAuthoredRecordKeys (function)", + "markFilterSubtreeProvenance (function)", "matchesLikePattern (function)", "missingFieldValues (function)", "nextUtcCalendarDay (function)", @@ -703,6 +707,7 @@ "resolveDisplayField (function)", "resolveDriverId (function)", "resolveEffectiveApiMethods (function)", + "resolveFilterSubtreeProvenance (function)", "resolveInjectedColumnProvenance (function)", "resolveInjectedSystemColumns (function)", "resolveRecordDisplayName (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index fbb638d6bc..a80896354a 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -243,6 +243,7 @@ "FILTER_LOGIC_CASES": "src/data/filter-logic-conformance.ts#FILTER_LOGIC_CASES (const)", "FILTER_LOGIC_ROWS": "src/data/filter-logic-conformance.ts#FILTER_LOGIC_ROWS (const)", "FILTER_OPERATORS": "src/data/filter.zod.ts#FILTER_OPERATORS (const)", + "FILTER_SUBTREE_PROVENANCE": "src/data/filter-subtree-provenance.ts#FILTER_SUBTREE_PROVENANCE (const)", "FILTER_TEXT_CASES": "src/data/filter-text-conformance.ts#FILTER_TEXT_CASES (const)", "FILTER_TEXT_ROWS": "src/data/filter-text-conformance.ts#FILTER_TEXT_ROWS (const)", "FILTER_TOKEN_WRAPPED_RE": "src/data/context-tokens.zod.ts#FILTER_TOKEN_WRAPPED_RE (const)", @@ -283,6 +284,7 @@ "FilterLogicCase": "src/data/filter-logic-conformance.ts#FilterLogicCase (interface)", "FilterLogicRow": "src/data/filter-logic-conformance.ts#FilterLogicRow (interface)", "FilterOperatorKey": "src/data/filter.zod.ts#FilterOperatorKey (type)", + "FilterSubtreeProvenance": "src/data/filter-subtree-provenance.ts#FilterSubtreeProvenance (type)", "FilterTextCase": "src/data/filter-text-conformance.ts#FilterTextCase (type)", "FilterTextRejectionCase": "src/data/filter-text-conformance.ts#FilterTextRejectionCase (interface)", "FilterTextRow": "src/data/filter-text-conformance.ts#FilterTextRow (interface)", @@ -633,6 +635,7 @@ "effectiveOperationsArray": "src/data/api-derivation.ts#effectiveOperationsArray (function)", "emptyGroupValueFor": "src/data/aggregation-policy.ts#emptyGroupValueFor (function)", "fieldForm": "src/data/field.form.ts#fieldForm (const)", + "filterSubtreeProvenanceOf": "src/data/filter-subtree-provenance.ts#filterSubtreeProvenanceOf (function)", "foldAsciiCase": "src/data/filter.zod.ts#foldAsciiCase (function)", "foldQueryAliasSlots": "src/data/data-engine.zod.ts#foldQueryAliasSlots (function)", "formatUnknownAuthoringKey": "src/data/authoring-key-lint.ts#formatUnknownAuthoringKey (function)", @@ -677,6 +680,7 @@ "likePatternToGlobPattern": "src/data/filter.zod.ts#likePatternToGlobPattern (function)", "likePatternToRegexSource": "src/data/filter.zod.ts#likePatternToRegexSource (function)", "lintAuthoredRecordKeys": "src/data/authoring-key-lint.ts#lintAuthoredRecordKeys (function)", + "markFilterSubtreeProvenance": "src/data/filter-subtree-provenance.ts#markFilterSubtreeProvenance (function)", "matchesLikePattern": "src/data/filter.zod.ts#matchesLikePattern (function)", "missingFieldValues": "src/data/autonumber-format.ts#missingFieldValues (function)", "nextUtcCalendarDay": "src/data/calendar-day.ts#nextUtcCalendarDay (function)", @@ -703,6 +707,7 @@ "resolveDisplayField": "src/data/display-name.ts#resolveDisplayField (function)", "resolveDriverId": "src/data/driver/config-registry.zod.ts#resolveDriverId (function)", "resolveEffectiveApiMethods": "src/data/api-derivation.ts#resolveEffectiveApiMethods (function)", + "resolveFilterSubtreeProvenance": "src/data/filter-subtree-provenance.ts#resolveFilterSubtreeProvenance (function)", "resolveInjectedColumnProvenance": "src/data/injected-system-column-provenance.ts#resolveInjectedColumnProvenance (function)", "resolveInjectedSystemColumns": "src/data/injected-system-columns.ts#resolveInjectedSystemColumns (function)", "resolveRecordDisplayName": "src/data/display-name.ts#resolveRecordDisplayName (function)", From 04455dc441a0bc1906e279db5e76b6311ab6d551 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 13:31:41 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(service-analytics):=20add=20the=20missi?= =?UTF-8?q?ng=20.js=20extension=20on=20the=20provenance=20test's=20strateg?= =?UTF-8?q?y=20import=20=E2=80=94=20clears=20the=20+1=20type-check-debt=20?= =?UTF-8?q?drift=20(#8220)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new `src/__tests__/read-scope-provenance-mark.test.ts` imported `../strategies/objectql-strategy` without the ESM file extension. The repo resolves as NodeNext, so tsc raised TS2835 there: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean '../strategies/objectql-strategy.js'? service-analytics is DEBT-ledgered and has no `typecheck` script, so the workspace typecheck never read the file; only CI's full-closure `check:type-check-debt --re-measure` saw the count move 10 -> 11. Vitest resolved the extensionless specifier either way, which is why the suite was green while the gate was red. Every other `../strategies/*` import in this package's tests already carries `.js`. Raw `tsc --noEmit -p packages/services/service-analytics/tsconfig.json` is back to 10, and the surviving 10 match the ledger note exactly (TS6133 x1 + TS2339 x7 + TS7053 x2). The DEBT entry is untouched and the pre-existing 10 are left alone. --- .../src/__tests__/read-scope-provenance-mark.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/service-analytics/src/__tests__/read-scope-provenance-mark.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-provenance-mark.test.ts index 58df6c1f71..97663d55b9 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-provenance-mark.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-provenance-mark.test.ts @@ -23,7 +23,7 @@ import { describe, it, expect } from 'vitest'; import { filterSubtreeProvenanceOf } from '@objectstack/spec/data'; -import { ObjectQLStrategy } from '../strategies/objectql-strategy'; +import { ObjectQLStrategy } from '../strategies/objectql-strategy.js'; /** Reach the private merge exactly as `execute()` calls it. */ const withReadScope = (