Repository navigation
fix(objectql,metadata-protocol): the data door honours a field's internal flag in its filter, sort, group-by, aggregate, $search and $expand positions (#22646) - #22702
Conversation
…usals (#22646) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
…ons + changeset (#22646) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
…drop read-options erasure, record double ledger (#22646) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
📓 Docs Drift CheckThis PR changes 2 package(s): 29 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6b3ba9d5f1e10e4ae2422f9c2d7df8af300cb3c2 && git checkout 6b3ba9d5f1e10e4ae2422f9c2d7df8af300cb3c2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 61bea24207c5d366b5b59568890b2567caf89d29 4191804adbfba888048b4960c4c72eab41f76a45 && git checkout -B drift-repro 61bea24207c5d366b5b59568890b2567caf89d29 && git merge --no-ff 4191804adbfba888048b4960c4c72eab41f76a45
node scripts/docs-audit/affected-docs.mjs --json 61bea24207c5d366b5b59568890b2567caf89d29
|
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
…pand level for internal fields (#22646) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
…valuate position (#22646) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
…positions (#22646) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
Contract reviewServed-tier: Card #22646 ( Check-runs on the head: 42 runs, 38 ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each judged against the head's own code:
Nothing judged wrong. ② Semver level
③ Boundary flagsDev
Nothing escalated. Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #22646
Clause-②: no (narrowing)
Summary
A field declared
internal: trueis withheld from every generic exit (#21197): the engine omits it from every row. The ROW position honoured that; the generic data door's EVALUATE positions did not, so a value the platform withholds from rows could still be learned through a position that answers by its stored value. Class ① product defect,security— class, position and function level only.Every position now judges the one flag reader (
collectInternalReadFieldsin@objectstack/objectql, or its byte-identical@objectstack/coretwincollectInternalWriteResponseFieldson the door, which sits below objectql), and a position that reveals a stored value is refused with a declared ADR-0112 envelope —INVALID_FIELD/ 400. No new error code;Clause-②staysno.Measurement (real stack, before)
Measured on
origin/mainas a seeded administrator and an ordinary member, on the list and query data routes, over a platform object'sinternal: truecredential-digest column and a non-hiddeninternaltext column (the dev's report on #22646 holds the run; ⛔ no request shapes here):filter=, querywhere, nested$or, cross-field comparand): served — the predicate reached the driver, a confirmation oracle.$orderby): served — the order leaked the comparative stored value.500 INTERNAL_ERROR(ObjectQL.rejectCredentialAggregationthrew a bareError).aggregations[].filter) and filter beside an aggregation: served — the same oracle at a second filter position.internaltext column entered the auto-default scan set and an explicit$searchFieldsnaming one was accepted.where/orderByon the target object'sinternalfield reached the expansion sub-read — the expanded record's presence is an oracle on the related row.$or, and in the count total), measured in patch round 1 on fixture objects: served — the parent row came back only when the related object'sinternalvalue matched. The same condition insideaggregations[].filterwas already refused (INVALID_FILTER), and the dotted array spelling already refused (INVALID_FIELD).INVALID_SORT) and the group-by gate refuses it as an unknown field (INVALID_FIELD); a dotted sort inside an$expandentry is admitted but changes no answer. No code for this position.$expanddeeper than one level: served — a depth-2 entry's ownwhere, and a depth-1 entry whosewhereis a relation condition onto the grand target, made the expanded record's presence depend on the stored value.Fix, by location
packages/objectql/src/engine.tsrejectCredentialAggregation: the refusal now carries the ADR-0112 envelope (INVALID_FIELD/ 400, located at object + field) instead of the bareError. Covers the group-by and aggregate-operand positions for every caller; thesecret/passwordcase that shared the bareErroris upgraded with it.expandSearchOnAst:internalfields are dropped from the set the search expansion resolves over (a withhold, like the auto-default'shidden/ credential-type exclusions), so$searchnever scans one — forfind/findOne/aggregate, and forsearchAllthrough them.packages/metadata-protocol/src/protocol.ts(the generic data door,findData)assertNoInternalFieldEvaluated: refuses aninternalfield in the filter, sort, group-by, aggregate-operand and per-aggregation-filter positions, after the field-existence gates and before the engine — the shape, envelope and caller-independence of the stored-metadata body/hash family one axis over.refuseInternalInFilter, which judges the filter's own reads and then each nested-relation condition (found byrelationConditionSites) recursively against the RELATED object's own flag reader; the refusal names the object that declares the field.refuseInternalInExpandwalks the whole$expandtree: every level'swhereandorderByagainst that level's target object.assertSearchFieldsAreSearchable: an explicit$searchFieldsnaming aninternalfield is refusedINVALID_FIELD/ 400 rather than silently widened back to the default set.The engine's privileged consumers (the credential verifier's lookup by its stored digest under a system context; the share-link / SCIM / approval-token lookups) call the engine directly and never pass the generic data door, so authentication is untouched — exactly as #7823 kept the engine's write results whole while the generic-data-path ingress stripped them. This is pinned by the
NEGATIVEcase in the dogfood file and byinternal-fields.test.ts's "keeps the field usable as a WHERE filter".Compile-surface checklist
No filter COMPILATION semantics change; the fix is a refusal at the data-door ingress (and the engine aggregate-refusal envelope + the search field-set withholding), before any filter compiler runs. Each named surface is out of scope:
driver-sqlapplyFilterCondition(anddriver-sqlite-wasm/ localdriver-tursoby inheritance) — out of scope: untouched; refusal precedes the driver.driver-tursoRemoteTransportbuildWhereSQL— out of scope: untouched.compileScopedFilterToSql— out of scope: untouched; the analytics door's own internal-field refusal landed in PR fix(service-analytics)!: the analytics door judges the generic-exit declarations — an object's enable block and a field's internal flag (#22634) #22645 (security(analytics): the ad-hoc analytics query serves objects that declareapiEnabled: falseand columns declaredinternal: true, which every other generic exit refuses or withholds #22634).lowerAnalyticsWhere— out of scope: untouched.formulamatchesFilterCondition— out of scope: untouched.applyHaving/matchesHaving— out of scope: untouched;havingnames the aggregated row's columns, where an internal field cannot be named.driver-mongodbtranslateFieldOperators— out of scope: untouched.Tests
packages/objectql/src/internal-field-positions.test.ts(aggregate envelope +$searchwithhold, with controls),packages/metadata-protocol/src/protocol.data-door-internal-field-positions.test.ts(every evaluate position refused, controls),packages/qa/dogfood/test/internal-field-data-door-positions.dogfood.test.ts(member + admin on a real stack, plus the authentication-still-works negative).$searchwithhold and the explicit-$searchFieldsarm were each mutated on disk (scripts/ablation-replace.mjs), the pins observed to fail, and the file restored byte-for-byte (git diff HEADempty).@objectstack/objectqland@objectstack/metadata-protocolbuild and typecheck clean. Affected-surface regression: objectql aggregate/search/judge files (145 tests) and metadata-protocol data-door/search files (159 tests) pass. Implicated gates run green locally (changeset family,error-status-conformance,dispatcher-error-vocabulary,engine-double-contract,test-source-alias,query-options-erasure,where-matcher,filter-alias-parity,issue-citations,nul-bytes, and others); the full farm is CI's.Docs
The docs-drift list's hand-written pages were read, the data-door pages in full. One correction:
content/docs/api/error-catalog.mdx'sINVALID_FIELDcause said only that a named field does not exist; it now adds that the same code refuses an existinginternal: truefield in an evaluate position, naming the positions. No other page claims aninternalfield is usable in filter, sort,$searchor$expand.content/docs/releases/is untouched.Patch round 1 (seat review 6100268814)
New pins: 14 member + administrator refusal cases (relation condition in four spellings,
$expanddepth-2 filter and sort, depth-1 relationwhereonto the grand target) and 6 controls;protocol.data-door-internal-field-positions.test.ts41/41. Ablations from a committed head, restored byte for byte: the relation-site recursion emptied (10 of 41 red) and the expand-tree recursion removed (4 of 41 red). The engine is unchanged this round.Scope
Target-OBJECT exposure through
$expandis a separate axis (#22661). The cross-object search sweep (searchAll) is #22640's region; it inherits the engine's search-set withholding throughengine.findwith no change to its own code.🤖 Generated with Claude Code
https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T