Skip to content

fix(security,service-analytics)!: the security contract publishes which fields a caller may query on, and the analytics field gate refuses a masked field as a group or filter member (#20935) - #20955

Merged
os-justin merged 7 commits into
mainfrom
claude/issue-20935-masked-not-queryable
Sep 30, 2026
Merged

os-justin merged 7 commits into
mainfrom
claude/issue-20935-masked-not-queryable

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Closes #20935
Clause-②: yes (narrowing)

A field whose maskingRule applies to the caller is SERVED, with its value replaced, so the published read projection ISecurityService.getReadableFields counts it readable. It is not queryable: the engine refuses it as a group key, an aggregate input, a filter or a sort key, because each of those gives back what the mask hides. The analytics field gate from #20917 was exactly as wide as the read projection. So a masked-for-this-caller field could be grouped or filtered by on the native-SQL strategy, which compiles its own statement. The engine and the ObjectQL strategy refuse the same query.

This PR publishes the missing answer on the security contract, implements it from the one decision the engine already makes, and has the analytics gate ask it. The masking rule itself is unchanged, and the analytics layer does not re-derive it.

What changed, per surface

  • Contract (packages/spec/src/contracts/security-service.ts). ISecurityService gains an optional getQueryableFields(object, context). It answers the fields the caller may filter, sort, group or aggregate by: the exact complement of what the engine's field guards refuse. It is a subset of getReadableFields, and the two differ by exactly the fields the caller is served masked. It has the same two empty answers as its siblings: undefined is no answer, [] is none. A system context gets every field.
    • Its absence is not soft. A consumer that cannot get this answer (the method is missing, or it answered undefined) must treat every field that declares a maskingRule as not queryable, whoever the caller is. Falling back to the read projection alone would admit exactly the masked fields.
  • Implementation (packages/plugins/plugin-security/src/security-plugin.ts).
    • The engine's predicate guard (step 2.9) and aggregate-input guard (step 2.5b) each built their "not queryable" map inline. They now call one private derivation, computeQueryGuardFieldPerms: the evaluator map, the requiredPermissions fold, the on-behalf-of delegator intersection, then every masked-for-this-caller field folded in as non-queryable.
    • getQueryableFields reads that same map, so a field is in the answer if and only if a query naming it passes both guards. The guards refuse exactly what they refused before.
    • It is registered on the security service beside getReadableFields.
  • Gate (packages/services/service-analytics/src/field-read-admission.ts + minimal wiring in analytics-service.ts and plugin.ts).
    • AnalyticsServiceConfig gains getQueryableFields. The door's one field gate asks it beside getReadableFields for the same objects. A member is admitted only when both answers carry its field. There is no second gate and no per-strategy copy.
    • A refused member answers 403 PERMISSION_DENIED in the engine's own words for that field. Those are the words the engine answers a masked field with.
    • A queryable reader that throws refuses the query, fail-closed and logged at error, the same way the read reader does.
    • AnalyticsServicePlugin bridges the hook to the security service with the same absent / unusable / usable resolutions as the read half, plus one more state. When a usable service predates the method, or answers undefined, the bridge fails closed: the read projection less every field whose declaration carries a maskingRule, for every caller. That over-refuses a caller for whom the rule is lifted, which is the safe direction. It is also the only one available, because deciding for whom a rule is lifted would be a second copy of the masking rule.
    • A host that constructs AnalyticsService itself with getReadableFields and without getQueryableFields is warned once at construction.

Why the PR line reads yes (widening), and the analytics changeset declares a narrowing

The line above is the claim's, as corrected under the seat's review adoption 5921385686 (scripts/pm/clause2-line.mjs:93: a diff that widens one surface and narrows another is spelled yes (narrowing)). The diff does widen the public surface: one optional contract member, its implementation on the service, and one optional config hook. It also narrows one accept set. Analytics queries that grouped, aggregated, filtered or sorted by a field the caller sees masked were answered on the native-SQL strategy (and printed by the SQL echo on both strategies), and they are now refused. That narrowing is declared where the level and ADR-0087 gates read it: in .changeset/20935-analytics-masked-field-not-queryable.md, which carries the **BREAKING** banner, its own Clause-②: yes (narrowing) line and an ADR-0087 not-required (no-migration-prescription) disposition, as #20917's changeset did. @objectstack/spec and @objectstack/plugin-security ship minor (widening). Every bumped package is minor, which is what yes requires. check-changeset-no-major and check-adr-0087-registration both exit 0.

Pins

  • packages/rest/src/analytics-masked-field-gate.test.ts covers both compositions over the real SecurityPlugin, ObjectQL and SqlDriver (native-SQL first, and ObjectQL only), with synthetic fixtures.
    • Two callers read the same fields: a member for whom each field's masking rule applies, and an unmasker who holds the capability that lifts it.
    • For the member, a masked field as a group member and as a filter member answers the engine's live refusal for the same field and caller (code, status and message equal). This holds on the base object, through an authored alias and through a joined object, on the cube read, the SQL echo and the dataset door. Neither the engine's aggregate nor its raw statement runs.
    • Controls: the unmasker is answered exactly as the system caller is, and the member is answered for fields it may query on.
  • packages/plugins/plugin-security/src/get-queryable-fields.test.ts is an equivalence. For every field and four positions (filter, sort key, group key, aggregate input), "the real middleware admitted it" equals "the field is in getQueryableFields". The cases cover a member, the capability holder, an agent alone and the same agent on behalf of a delegator. It also pins the contract's answers: masked means readable and not queryable, the system and no-sets answers, the unresolvable object, the dangling delegator, and registration on the service.
  • packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts runs the masked positions its cases name (six) through both strategy paths with nothing executed. It also covers the throwing reader, each reader judged on its own, the construction warning, and the bridge: service answer, rule lifted, the fail-closed fallback for a service that predates the method and for an undefined answer, and no security service.
  • packages/spec/src/contracts/security-service.test.ts pins the member's optionality (an unguarded call does not compile), the masked-readable-not-queryable relation and the two empty answers.

Ablations, predicted before running, at 20c56c9810

Each ablation followed the same legs:

  1. Mutate through scripts/ablation-replace.mjs: the anchor hits once and the blob moves.
  2. Where the suite reads dist/, rebuild the package and confirm with ablation-dist-preflight that the marker is in 2 built files.
  3. Run the suites.
  4. Restore, and prove the blob equals HEAD with an empty git diff HEAD.
  5. Rebuild, and confirm with the --absent preflight that the marker is gone from all 6 built files and the tree is clean.

Commits after 20c56c9810 touched only the bridge's fallback. It no longer exempts a system caller, which kept a new elevation read site out of the system-context census. None of the three ablations reaches that branch.

Verification, at fc844c433e (main merged at 95fed33a20)

  • @objectstack/service-analytics: test 147 files, 3398 passed, and typecheck 0 (the new test file is in the tsc program: it reported a tuple error before its fix). This run was taken at e84882a09f. The later edits were re-run: the two gate unit files, 72 passed, plus typecheck 0.
  • @objectstack/plugin-security: test 150 files, 3237 passed and 23 skipped, and typecheck 0 including the test layer.
  • @objectstack/spec: typecheck 0, src/contracts 45 files and 434 passed, and check:generated shows all 15 artifacts up to date.
  • @objectstack/rest: every analytics-* route file (13 files, 175 passed and 3 skipped), and typecheck 0 including the test layer. Both field-gate route files re-ran at 03153e00f1 after the second merge of main: 22 passed.
  • dispatch-gates --commands derived 87 commands at fc844c433e, with no stale tree.

Acceptance notes


Generated by Claude Code

…e the analytics field gate ask it

The security contract gains an optional getQueryableFields: the fields a
caller may filter, sort, group or aggregate by. plugin-security derives it
from the one query-guard map its predicate guard and aggregate-input guard
now share, so a field served masked is readable and not queryable. The
analytics field gate asks it beside the read projection; the plugin bridge
fails closed for masking-rule fields when the security service cannot answer.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…ytics strategies

A door-level pin over the real SecurityPlugin, ObjectQL and SqlDriver: a
field the caller is served masked, as a group or a filter member, answers the
engine's own refusal on both strategies before any strategy runs, and a caller
the masking rule is lifted for is the control. plugin-security pins that
getQueryableFields equals the middleware's two query guards field for field;
the analytics unit pins cover the gate, the bridge's fail-closed fallback and
the construction-time warning.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…ation and the analytics narrowing

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…ty, so it refuses masked-rule fields for every caller

The fallback for a security service that cannot answer getQueryableFields
exempted a system context by reading its system bit, a new elevation read
site. It cannot say for whom a masking rule is lifted, so it now refuses
every caller alike; the contract text and both changesets say so.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-security, @objectstack/service-analytics, @objectstack/spec, touching 16 documentable anchor(s).

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/index.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/kernel/runtime-services/index.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/system-context.mdx (via resolveProjectionFieldMask (symbol, a method of class SecurityPlugin))
  • content/docs/plugins/packages.mdx (via AnalyticsServicePlugin (symbol, a top-level class), SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v17/17-5.mdx (via AnalyticsService (symbol, a top-level class), ISecurityService (symbol, a top-level interface))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 140 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 013f97df93ed66d0aeec45ec1d507da303991260 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from bd3224fe607a26553bc16e6f45084848a96234e4 — the merge of head fc844c433ed2e8e175e4568db4e03a4e58b0c5c9 into base 013f97df93ed66d0aeec45ec1d507da303991260, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bd3224fe607a26553bc16e6f45084848a96234e4 && git checkout bd3224fe607a26553bc16e6f45084848a96234e4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 013f97df93ed66d0aeec45ec1d507da303991260 fc844c433ed2e8e175e4568db4e03a4e58b0c5c9 && git checkout -B drift-repro 013f97df93ed66d0aeec45ec1d507da303991260 && git merge --no-ff fc844c433ed2e8e175e4568db4e03a4e58b0c5c9

node scripts/docs-audit/affected-docs.mjs --json 013f97df93ed66d0aeec45ec1d507da303991260

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 013f97df93ed66d0aeec45ec1d507da303991260 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: fc844c433ed2e8e175e4568db4e03a4e58b0c5c9
Local-runs: none

PR #20955 for card #20935, read as the net diff against main (merge-base 95fed33a20, 12 files, +1243 / −47), the card and every comment on it (triage 5919588833, claim 5919731088, dev report 5921193653, ruling 5921222205), #20917 / PR #20931 (merged 1571aedce5), the #20933 triage note 5919572657, #20964, and the check-runs on this head. The card's disclosure discipline holds in this record: no request recipe, field spelling or returned value; tests are cited by name only.

① Derived judgments

Accept-set and public-surface changes the diff implies

  1. ISecurityService.getQueryableFields?(object, context) (packages/spec/src/contracts/security-service.ts) — a new OPTIONAL member on the published contract: a public-surface widening, and only that. Implementers keep compiling; the unguarded call does not (security-service.test.ts, the [#20935] getQueryableFields is OPTIONAL case uses a ts-expect-error pin). Right.
  2. The registered security service gains getQueryableFields beside getReadableFields (security-plugin.ts, the registration block and the registration log line). A runtime-surface widening; pinned by get-queryable-fields.test.ts is exposed on the registered "security" service. Right.
  3. AnalyticsServiceConfig.getQueryableFields? — a new optional config hook, bridged by AnalyticsServicePlugin. Widening. Right.
  4. The analytics accept set NARROWS: a member naming a field the caller is served masked is refused 403 PERMISSION_DENIED on the cube read, the SQL echo and the dataset door, on both strategies, before a strategy is chosen. This is the card's Direction and triage's floor. Right.
  5. The engine's two guards refuse exactly what they refused before. Read old against new: step 2.5b built evaluator map → requiredPermissions fold → delegator intersection, then a separate partial-mask map, and refused a referenced field when either map marked it; step 2.9 built the same three steps and folded every applicable mask rule in as non-readable. computeQueryGuardFieldPerms is step 2.9's sequence verbatim (same evaluator call, same fold, same intersection, same computePartialMaskRules fold with the same three arguments). For 2.5b the new predicate (readable === false on the folded map) equals the old disjunction because the fold sets exactly the masked keys to readable: false, and the new run condition (folded map non-empty) equals the old (either map non-empty) because the folded map's keys are the union. Ablation C in the dev report (two pre-existing field-masking-rule.test.ts pins go red when the fold is removed) is consistent with this reading. Right — no accept-set change in the engine.
  6. ONE derivation. computeQueryGuardFieldPerms is now the only site in plugin-security that folds masking into a query verdict; both guards and getQueryableFields (through resolveProjectionFieldMask.readQueryGuardPerms) read it, and get-queryable-fields.test.ts pins the equivalence against the REAL middleware for four positions (filter, sort key, group key, aggregate input) across a member, a capability holder, an agent alone and the agent on behalf of a delegator. In service-analytics, field-read-admission.ts compares names to two answers and derives nothing; plugin.ts's fallback reads the maskingRule DECLARATION off the data engine's schema and decides for nobody whom a rule is lifted for. There is no second masking rule anywhere in the diff. Right.
  7. "A subset of getReadableFields, differing by exactly the masked fields." Verified from computeReadableFields (readable = folded map not false, OR a read-side partial rule applies) against getQueryableFields (queryable = query-guard map not false): the query-guard map carries every false the folded map carries plus the applicable rules, so queryable ⊆ readable; the difference is the fields with an applicable rule, whether or not the fold already hid them; an explicit permission-set deny sits in neither. Right.
  8. Settled answers: undefined (schema unresolvable), the full set (system context; a caller with no permission sets — the middleware skips 2.5b, which requires sets, and 2.9's map is empty under the stand-in posture), [] (unresolved posture, dangling delegator — cases the middleware refuses outright). Right, each pinned in get-queryable-fields.test.ts.
  9. No route or strategy bypasses the gate. callCtx is the one thing query() and generateSql() share; POST /analytics/query and POST /analytics/sql (packages/runtime/src/domains/analytics.ts) call those two, POST /api/v1/analytics/dataset/query reaches query() through DatasetExecutor, and the draft-preview branch of queryDataset calls the same assertFieldsReadable, which is the single call site that now passes queryableFieldsProvider. GET /analytics/meta serves no values. FallbackDelegateStrategy (a delegated MemoryAnalyticsService) is selected after callCtx. Right.
  10. Every member slot. namedQueryFields collects dimensions, time dimensions, measures, where, a measure's own filter, the dataset's own filter and order; the remaining AnalyticsQuerySchema keys (limit, offset, timezone) name no field. The queryable check sits inside the one hidden predicate applied to every named field, so a masked field is refused in every slot and the role decides only the words. Right.
  11. The fail-closed fallback (plugin.ts): a usable service that lacks the method or answers undefined gets the read projection less every field declaring a maskingRule, and the bridge reads no caller property (the head commit removed the earlier system exemption; the bridge carries no isSystem read). A system or elevated caller is over-refused rather than served, and the fallback's only read is getObject on the data engine — schema declarations, not rows — so it opens no data read site. Pinned by field-query-admission-gate.test.ts fails CLOSED for a security service that predates getQueryableFields (which also refuses the system caller) and fails CLOSED the same way when getQueryableFields answers "no answer" (undefined). Right. Disclosed residual: an object the data engine does not register contributes no declarations, so under the fallback ONLY (a security service older than this member) such an object's masked fields would pass; the native strategy compiles against the data engine's registry, so the reach is narrow.
  12. The unmasked reader of the same field is served: analytics-masked-field-gate.test.ts the control: the unmasker is answered for the same members exactly as the system caller is and the control: the member is answered for the fields it may query on, on both compositions over the real SecurityPlugin, ObjectQL and SqlDriver, each refusal compared for code, status and message against the engine's live answer for the same caller. Right.

Author-shown and AI-facing text, sentence by sentence (only the ones that are false, unsourced or over-broad are listed; everything else tested true against the tree)

  • PR body, Pins: "field-query-admission-gate.test.ts runs every masked position through both strategy paths" — over-broad. Its table has six positions (group, filter, order key, authored-alias group, joined group, joined filter); it carries no measure / aggregate-input case and no time-dimension case. The behaviour holds by construction (item 10) and the aggregate-input position is pinned on the engine side in get-queryable-fields.test.ts, but the sentence over-describes that file. Never true as written; the six it runs are true on main when this lands.
  • .changeset/20935-analytics-masked-field-not-queryable.md, "What is not affected": "A system context is unaffected." — over-broad. True on main when this lands for a lockstep deployment (the shipped service answers, and answers the full set for a system context). False under the same changeset's own "New hook" paragraph: where the security service predates the member, a system caller is refused masked-rule fields too. The sentence needs "unless the security service predates the member", or the changeset should drop it.
  • Same changeset, BREAKING banner: "on a SQL deployment" — over-narrow. The SQL echo moves from printed to refused on both strategies (the PR body says so itself), so the narrowing also reaches the /analytics/sql echo on a non-SQL deployment. The "What changed" paragraph names the three doors correctly.
  • PR body, Verification and Ablations — unsourced from this record's inputs; nothing was re-run. The claimed counts are internally consistent with the code reading above, and the gate verdicts are the check-runs below.
  • Tested true: the contract docblock (the two empty answers; system bypass; "the analytics raw-SQL path is the one in the tree" — packages/spec/liveness/analytics_cube.json records that no in-repo composition registers MemoryAnalyticsService as the analytics service); the header sentence that absence is the one non-soft case; the spec changeset ("fails soft" describes the answers, the fail-closed obligation the absence — consistent); the plugin-security changeset's "refuse exactly what they refused before" (item 5); the analytics changeset's prescription (the unmask gate IS the field's requiredPermissions, all held, per FieldSchema.maskingRule's own description; a rule declared with no capability has nothing to grant, which the sentence's second half covers); "warned once at construction" (the warn sits in the constructor under read-wired-without-query; the plugin always supplies both); "in the engine's own words" (fieldReadDeniedError spells the two engine sentences and the route pin compares message equality against the live engine); the AnalyticsServiceConfig docblock; the registration log line.
  • Disclosure discipline: the PR body, the three changesets and the three new tests carry no request recipe, field spelling or returned value from the private measurement; the fixtures are synthetic and say so.

② Semver level

  • @objectstack/spec minor — right. An optional member on a published interface is a purely additive widening: at least minor under WHICH LEVEL, not major (implementers keep compiling). Its changeset carries Clause-②: yes (widening), which is true of that package.
  • @objectstack/plugin-security minor — right. The security service gains a public method.
  • @objectstack/service-analytics minor, with the **BREAKING** banner, its own Clause-②: yes (narrowing) line and the ADR-0087 not-required (no-migration-prescription) marker — right. An accept-set narrowing is breaking and ships minor in the launch window; no authorable key, export or stored shape is removed or renamed, so there is nothing for objectstack migrate meta to rewrite; this is PR fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) #20931's disposition for the same door. check-adr-0087-registration's breakingDeclaration reads three signals from that file (the ! summary, the banner, the narrowing arm), so the breaking change is declared where consumers and the gate read it.
  • The PR body line Clause-②: yes (widening). yes is right: the level axis asks that a yes PR grade at least one moved package minor or above, and all three are minor. The ARM is the weaker of the two true arms. scripts/pm/clause2-line.mjs, the one reader, defines yes (narrowing) as "a diff that widens one surface and narrows another. Both facts are true and both are read", and yes (widening) as a widening said out loud — this diff is the former shape, and PR fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) #20931 (a config-hook widening plus the same door's narrowing) declared yes (narrowing). No gate outcome moves either way: the level axis needs only yes, and the ADR-0087 gate reads the arm from the changeset. Judged under-declared, not false: the PR-level carrier drops the breaking signal the arm exists to carry ([finding] An accept-set narrowing owes a **BREAKING** banner in core but not in platform-objects — and the ADR-0087 classifier reads the banner #16421). The seat should amend the line to yes (narrowing) — a PR-body edit that moves no head; this record stands on fc844c433e either way.

③ Boundary flags

Check-runs on fc844c433e (39 runs, 35 names after dedupe by newest started_at; none running, none failed): 30 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in) — conditional jobs). Named: Check Changeset (the level axis and the ADR-0087 registration), Lint & Repo Gates, Spec property liveness, Governed Surface Queue Guard, TypeScript Type Check, Type Check · consumer gates / debt ledger / source gates / workspace, Build Core, Test Core and Test Core (1/6) to (6/6), Dogfood Regression Gate and (1/3) to (3/3), Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Check Documentation Links, Flag docs affected by code changes, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch, filter — all success.

Implemented-by: claude/issue-20935-masked-not-queryable
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS

Adopted and posted by domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-09-30T23:16Z · rendered by the seat's at-tier review subagent on this head. The seat read its served tier family from the subagent transcript before posting. ⚠️ The disclosure discipline holds.


Generated by Claude Code

@os-justin
os-justin marked this pull request as ready for review September 30, 2026 23:19
@os-justin
os-justin added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 83480c6 Sep 30, 2026
51 checks passed
@os-justin
os-justin deleted the claude/issue-20935-masked-not-queryable branch September 30, 2026 23:41
os-justin pushed a commit that referenced this pull request Oct 1, 2026
main's #20931 (the field-read admission gate), #20955 (the queryable-field
gate), #20954 (plugin-security's comparand guard) and #20962 (relationship
path objects in the admitted and scoped set) touched
packages/services/service-analytics. The merge is clean at the text level;
both sides' additions to analytics-service.ts and native-sql-strategy.ts are
kept whole.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants