Skip to content

fix(service-analytics)!: the analytics door judges the generic-exit declarations — an object's enable block and a field's internal flag (#22634) - #22645

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-22634-analytics-exposure-gate
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-22634-analytics-exposure-gate

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22634
Clause-②: no (narrowing)

The analytics door now judges the two generic-exit declarations every other door judges:

  • The object facet. An object's enable block is judged through the spec's one exposure decision, apiExposureDenialReason, for the aggregate operation.
  • The field facet. A member that reads a field declared internal: true is refused.

Both bind every caller, administrators included, on both strategies, and both are asked before any strategy is selected.

Named gap — one CI gate is red by design, outside this claim's file surface. check:error-code-provenance (spec) reds on the two new stamp sites: @objectstack/service-analytics now emits OBJECT_API_DISABLED and OBJECT_API_METHOD_NOT_ALLOWED, and the ledger lists both codes under @objectstack/rest only. Closing it is a ledger row (or a provenance waiver) in packages/spec/src/api/error-code-ledger.zod.ts, and the claim fences packages/spec off. The constants are deliberately named *_CODE so the gate sees them; a spelling the gate is blind to would have hidden the drift. The decision is with the seat (see the report on #22634).

Step 1 — the measurement, by class

The measurement used a real stack: @objectstack/verify bootStack with the real SecurityPlugin, on sqlite-wasm. The native-SQL strategy answered on that driver; the ObjectQL strategy was forced by withholding the native-SQL capability. Callers were a member holding read on every subject through a fallback permission set, and the seeded platform administrator. Subjects were two platform credential stores (sys_jwks, sys_oauth_access_token), sys_session, and fixture objects for the classes no platform object isolates. The probe was a scratch file and is not committed. No values are quoted below.

Before, on origin/main f368b7e980. "Served" means 200 with the rows; for a column, the stored value came back as a group key, or the filter answered differently for a stored value and for any other value.

Class Doors Native SQL ObjectQL Data door, same declaration
Object declaring apiEnabled: false ad-hoc query, SQL face, configured cube, dataset served, member and admin served, member and admin 404 OBJECT_API_DISABLED
Object whose apiMethods omits list same four served served 405 OBJECT_API_METHOD_NOT_ALLOWED
internal field as a dimension ad-hoc, configured cube, dataset value served as the group key. The member got it on the fixture object (row scope hid the credential stores' rows from the member); the admin got it on sys_jwks and sys_oauth_access_token too engine refusal, undeclared 500 aggregate face: engine refusal, undeclared 500
internal field as an aggregate input ad-hoc served undeclared 500 —
internal field as a filter operand ad-hoc, configured cube served (oracle) served (oracle) admitted (oracle) — see Acceptance notes
internal field in the SQL face SQL face statement compiled statement compiled —
Controls: an ordinary object, an ordinary field all served served served

After, on 013c717f91 (this branch merged with origin/main). Same stack, same subjects, both strategies, both callers, one answer per row:

Class Every analytics door, both strategies, member and admin
Object declaring apiEnabled: false 404 OBJECT_API_DISABLED, in the data door's words
Object whose whitelist omits list 405 OBJECT_API_METHOD_NOT_ALLOWED
internal field, in any position (dimension, measure input, filter, SQL face, configured cube, dataset) 400 INVALID_FIELD, naming the object and the field
internal field on an apiEnabled: false object 404 OBJECT_API_DISABLED (the object answers first)
Controls 200, unchanged: an ordinary column, a bare count over an object that has an internal field, sys_session counted

sys_approval_token is apiEnabled: false since #22633 landed. With ApprovalsServicePlugin mounted, it answered 404 OBJECT_API_DISABLED on every door, both strategies and both callers. The codes reach the wire on all three route families: /analytics/query and /analytics/sql through the runtime dispatcher, and /analytics/dataset/query through the REST server.

Census

Read from the built object definitions on f368b7e980, plus #22633:

  • apiEnabled: false: sys_flow_credential, sys_jwks, sys_oauth_access_token, sys_oauth_client_assertion, sys_oauth_client_resource, sys_oauth_consent, sys_oauth_refresh_token, sys_oauth_resource, and since fix(plugin-approvals)!: sys_approval_token, the action-link tokens, is no longer exposed through the automatic API (#22616) #22633 sys_approval_token. Four of them also declare an internal field: sys_jwks, sys_oauth_access_token, sys_oauth_refresh_token, sys_approval_token.
  • Whitelist without list: sys_device_code, sys_two_factor, sys_verification.
  • internal fields on exposed objects: sys_account (5), sys_api_key, sys_email, sys_http_delivery, sys_oauth_application, sys_scim_connection_credential, sys_session, sys_share_link (2), sys_sso_provider (2), sys_two_factor (2), sys_verification (2).
  • Authored analytics: no fork. No authored cube, dataset, dashboard or example names any of these objects or fields. examples/ declares no apiEnabled: false, no apiMethods and no internal. The only authored analytics over sys_* objects are the System Overview datasets. They count sys_user, sys_organization and sys_session, and group sys_audit_log by action and user_id. Each of those objects grants list, and none of those fields is internal.

Which precedent decided refused vs withheld

Measured on the data door, origin/main, both callers:

Every analytics member is evaluated, never projected: a group key, an aggregate input, a predicate. So nothing can be omitted, and the governing precedent is the aggregate face's: refuse. The card's Ask names the filter position too. The envelope is the one this door already uses for a member it will not evaluate whoever asks, the stored-metadata-body refusal (#21120): 400 INVALID_FIELD, naming the object and the field.

The change

packages/services/service-analytics/src/api-exposure-door.ts, new:

  • assertObjectsExposed asks apiExposureDenialReason(enable, 'aggregate'). It honours both answers: off switch → 404, whitelist → 405 with the effective set. It holds no rule of its own.
  • assertNoInternalFieldNamed asks @objectstack/core's collectInternalWriteResponseFields, the collector every write mouth and the knowledge index already reuse, over what namedQueryFields resolves (dimensions, measures, filters, sort keys, dataset filters, relationship hops).
  • Fail-closed: a lookup that throws refuses the query with 403 PERMISSION_DENIED, logged at error.

analytics-service.ts asks the gates at these seams:

plugin.ts wires getObjectDeclaration from the data engine's getObject(), and throws (refuses) when no engine can answer. There is no system carve-out and no persona input. A host that builds AnalyticsService by hand with no hook gets no gate and a one-time warning, the same as the other providers.

Bounded in-place widening, stated. The card names the off switch. Honouring only that half of the decision would be a second rule: the whitelist read as unrestricted at this door alone. So the 405 facet is included. It is the same defect class, in the same file, with the same envelope family. No in-repo analytics reads an object whose whitelist omits list (census above).

Pins and their ablation

src/__tests__/api-exposure-door.test.ts: 30 cases.

  • Harness: a real ObjectQL engine and a real SqlDriver, under AnalyticsServicePlugin's own composition, on both strategies. Two callers: a member and an administrator, each granted read on every object and field by the security double.

  • Negative pins, per facet: off switch, whitelist, no minting on refusal, internal field in eight positions, internal field through a hop. Each asserts the envelope (code + status + object [+ field]) and that nothing read the object.

  • Controls: an ordinary object and field; an object that has an internal field, served for its other fields and through a hop.

  • Fail-closed: a throwing lookup; a plugin with no data engine.

  • Red first: at 6c5eb0278f (the pins only, on the unmodified door), 22 failed / 8 passed. The 8 are the controls. The reds were rows served, or the engine's undeclared refusal.

  • Green: at 160b2200db, 30/30.

  • Ablation: run through scripts/ablation-replace.mjs at a7c40fbb2d. Every anchor hit as declared and every blob changed. Each restore is proven by the blob equalling HEAD with git diff HEAD empty. The subject is imported from source, so no build sits between mutation and run.

    • A, the decision bypassed: 12 red (404 ×4, 405 ×4, no-mint ×4).
    • B, the internal check bypassed: 8 red.
    • C, the fail-closed catch neutralised: 2 red.
    • D, both entry gates removed: 4 red (no-mint only — the callCtx half still refuses, so the entry gate is what keeps a refused request from inferring a cube).

Tests and gates

All at 013c717f91, this branch merged with origin/main, unless a line says otherwise.

  • @objectstack/service-analytics test: vitest run --maxWorkers=2, 181 files, 4473 passed / 262 skipped.
  • @objectstack/service-analytics typecheck: tsc --noEmit exit 0. The package tsconfig compiles the tests; it caught one unused parameter in the pins, fixed in a7c40fbb2d.
  • @objectstack/spec test:repo: 54 files / 915 tests passed. The new stamp sites are in that project's scan reach.
  • Derived gates: dispatch-gates --commands --repo objectstack-ai/objectstack (no paths) derived 65 commands from the real change set (5 paths vs merge base 86da19491). All 65 exited 0, each exit captured before any pipe. --ran with exit codes recorded: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, packages without dist/); after the full build (72 tasks, 71 cached) it exited 0 (107 entries load).
  • Not derived, run beside them:
    • check:adr-0087-registration exit 0: the changeset reads not-required (no-migration-prescription).
    • check-changeset-no-major exit 0.
    • check:error-code-provenance (spec) exit 1: the named gap above, two stamp sites and nothing else (337 sites: 317 listed, 18 waived).
  • Lint, narrowed: eslint --no-inline-config on the 4 changed .ts files.
    • All 4 are in the population: --print-config resolves 6, 6, 6 and 5 active rules.
    • --format json gives 4 files, 0 errors, 0 warnings.
    • The config enables no type-aware linting (no parserOptions.project, no projectService), so the verdicts of untouched files cannot move.
    • The repo-wide pnpm lint is CI's.
  • Real-stack measurement: a scratch probe through bootStack, before and after; the tables above. Not committed.
  • CI: not awaited.

Acceptance notes

  • The data door admits a filter on an internal field. On origin/main, its list and query routes answer differently for the stored value and for any other value. That is a confirmation oracle over a withheld value, for any caller holding read. It is outside this card's surface and reported to the seat as a security finding.
  • The engine's credential-aggregation refusal reaches the data door's aggregate face as an undeclared 500. It is a refusal of the caller's request, not a server fault. Reported to the seat.
  • check:error-code-provenance is red until the ledger row lands (the named gap above).
  • A plugin composed with no data engine now refuses every analytics query. The declarations live in that engine. The package suite (181 files) composes none that way.
  • GET /analytics/meta still lists a configured cube over an apiEnabled: false object. That is metadata, not rows. Querying the cube is refused. Not changed here.
  • Registration is not refused. registerDataset does not refuse a dataset over such an object; the query is refused instead. An authoring-time refusal would be a new gate, and none is added.
  • The dimension-label pass reads a lookup target's display field. Neither that pass nor the data door's $expand judges the target's exposure. No in-repo reach: the only lookup into an apiEnabled: false object starts from another one.
  • @objectstack/mcp's stdio bridge stamps OBJECT_API_DISABLED without a ledger row of its own. Its constant's name is outside the provenance gate's declared patterns. Noted, not filed.

Seat's append: patch round 1 (head 6709a20427)

Appended by domain:services seat 1 (session_013j5gkUCpqQiti4GgPqqmnt) at 2026-10-10T09:23Z, from the dev's round-1 report (6096072918 on #22634).

  • The seat answered open question 1 with A: claim amendment 6095740822, with the spec-lane declaration on [PM seat] domain:spec — 🟢 os-project-manager · session_01S3aAf11JjbW1mSGL1EhfFj #6017.

  • The data door's two findings are filed as security(data): the data door's filter and group-by positions do not honour a field's internal: true the way its row read does — detail withheld pending maintainer #22646: a filter on an internal field, and the group-by 500.

  • The ledger row. In packages/spec/src/api/error-code-ledger.zod.ts, the existing '@objectstack/service-analytics' row now lists OBJECT_API_DISABLED and OBJECT_API_METHOD_NOT_ALLOWED, in alphabetical order. A comment names the measured wire paths: /analytics/query and /analytics/sql through the runtime dispatcher, and /analytics/dataset/query through rest. The change is value-only: no other spec file, no export, no schema change.

  • Nothing generated moved.

    • REGISTERED_ERROR_CODES is byte-identical before (e2b9e83626) and after: 279 codes, the same array, the same sha256 prefix b4910b3ab70945c7.
    • Exactly one owner row of 32 changed.
    • check:generated reports all 15 generated artifacts up to date and nothing regenerated.
  • check:error-code-provenance: exit 0. 337 stamp sites: 319 listed (was 317), 18 waived.

  • Changeset. The ledger row ships in @objectstack/spec's published files, so it gets its own patch changeset, .changeset/22634-spec-ledger-analytics-exposure-codes.md. That keeps the analytics BREAKING narrative out of spec's CHANGELOG.

  • origin/main merged twice, as d85615ddd9 and cc305a3cfc. Both merges were clean, with no regeneration debt.

  • Gates and tests at 6709a20427:

    • dispatch-gates --commands derived 90 commands from 7 paths. All 90 exited 0, and --ran reports 90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN. check:adr-0087-registration and check:error-code-provenance are among them.
    • @objectstack/service-analytics: 181 files, 4473 passed and 262 skipped. Typecheck exits 0.
    • @objectstack/spec: test (local) 642 files, 19171 passed. test:repo 54 files, 915 passed. Typecheck exits 0.
    • Narrowed lint on the 5 changed .ts files: 0 errors, 0 warnings.

Generated by Claude Code

…e analytics door

Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt
Co-authored-by: Claude <noreply@anthropic.com>
…clarations every other door judges

An object's enable block is asked through the spec's one exposure decision
for the aggregate operation, and a member reading a field declared
internal: true is refused in every position, for every caller, ahead of
strategy selection.

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

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-analytics, @objectstack/spec, touching 33 documentable anchor(s).

18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json cc305a3cfc730dcf538c791fa5ff43d3ad3a7c4b.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: PERMISSION_DENIED (symbol, 31 pages), PERMISSION_DENIED (literal, 31 pages)
  • 5 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 — 139 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 cc305a3cfc730dcf538c791fa5ff43d3ad3a7c4b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from bcee50ca7fc3e8b3f3fe8163c5c8d23aa824f28e — the merge of head 6709a204273e6a5af101d9d94ea1a7e4aa97c126 into base cc305a3cfc730dcf538c791fa5ff43d3ad3a7c4b, 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 bcee50ca7fc3e8b3f3fe8163c5c8d23aa824f28e && git checkout bcee50ca7fc3e8b3f3fe8163c5c8d23aa824f28e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cc305a3cfc730dcf538c791fa5ff43d3ad3a7c4b 6709a204273e6a5af101d9d94ea1a7e4aa97c126 && git checkout -B drift-repro cc305a3cfc730dcf538c791fa5ff43d3ad3a7c4b && git merge --no-ff 6709a204273e6a5af101d9d94ea1a7e4aa97c126

node scripts/docs-audit/affected-docs.mjs --json cc305a3cfc730dcf538c791fa5ff43d3ad3a7c4b

⚠️ 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 cc305a3cfc730dcf538c791fa5ff43d3ad3a7c4b → 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: 6709a204273e6a5af101d9d94ea1a7e4aa97c126
Local-runs: none

Inputs: card #22634 (body and its four comments: claim 6095153388, round-0 report 6095724958, claim amendment 6095740822, round-1 report 6096072918); PR #22645 (body, the 7-file list, the net diff against origin/main at the head: +865 / -0); the check-runs on the head; and the two verifications the brief named, #22646 and the #6017 declaration. Nothing was built, run or re-run. Rendered 2026-10-10T09:36Z.

① Derived judgments

Gate verdicts. All 35 named check-runs on the head are complete, read 2026-10-10T09:36Z: 30 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke — path-conditional, not failures), 0 queued or in progress. Lint & Repo Gates (hosts check:error-code-provenance and check:generated), Check Changeset (hosts check-changeset-no-major and check-adr-0087-registration), Governed Surface Queue Guard, Test Core 1–6, Dogfood Regression Gate 1–3, Dogfood Verify CLI, the four Type Check jobs and Temporal Conformance are green. No red to attribute.

1. Object facet — the accept set narrows. Every object a query reads (base, declared joins and include, relationship hops) must satisfy apiExposureDenialReason(enable, 'aggregate') === null; otherwise 404 OBJECT_API_DISABLED or 405 OBJECT_API_METHOD_NOT_ALLOWED. RIGHT. The decision is reused, not copied: api-exposure-door.ts imports it from @objectstack/spec/data and holds no rule of its own — no apiEnabled test, no whitelist reading; the 405 effective set comes from the spec's own effectiveOperationsArray(resolveEffectiveApiMethods(enable)). aggregate is a canonical derived ApiOperation (DATA_ACTION_TO_API_OPERATION.aggregate is identity; API_METHOD_DERIVATION.aggregate is all: ['list']), the same operation the runtime dispatcher resolves for its own aggregate action, so a whitelist that grants list is still served here. The only locally spelled part is the envelope (message, code, status, object, allowed), which is the pattern the spec's own doc prescribes for senders and what the runtime dispatcher and the MCP bridge do; service-analytics does not and must not depend on @objectstack/rest, where apiAccessDenialFromEnable lives. Both codes are typed RegisteredErrorCode, so a misspelling fails tsc.

2. Field facet — the accept set narrows. Any member that resolves to a field declared internal: true — dimension, timeDimension, measure input, where leaf, measure filter, dataset filter, order key, relationship-hop column — is refused 400 INVALID_FIELD, naming object and field and no value. RIGHT. The reader is reused: collectInternalWriteResponseFields from @objectstack/core (exported at core's index), strict === true, the same predicate as objectql's engine-private collectInternalReadFields; no third list is introduced. Refused rather than withheld is the correct reading of the data door's precedent: the row path omits because a row is a projection; the aggregate face refuses because a group key cannot be omitted; every analytics member is evaluated. The envelope is the one this door already uses for the stored-metadata-body refusal.

3. Admission order. RIGHT. In queryIn the object gate runs after assertCubePublic and before withDeclaredGranularityDefaults, ensureCube and callCtx; in generateSql before ensureCube; in answerDataset before compile. A refused ad-hoc request therefore infers nothing and the #20381 request scope stays empty — pinned by the "mints nothing" case (inferCubeFromQuery spied, getMeta compared before and after). Inside callCtx the order is exposure → read admission → stored-body refusal → internal-field → field-level read → order-key door: the data door's order, declaration before permission.

4. Coverage. Every entry is covered, at the one seam ahead of resolveStrategy, so native SQL and ObjectQL inherit one verdict by construction: /analytics/query → query(); /analytics/sql → generateSql(); configured cubes through query() with namedCubeObjects = base + declared joins; /analytics/dataset/query (rest) → queryDataset → answerDataset (entry gate on the base object, before compile) → DatasetExecutor → queryIn in the compiled dataset's scope (base + include joins at entry, the full queryObjects set with hops in callCtx); the draft-preview branch (object gate over the compiled cube's objects, field half per executor query). metadata-protocol's build probe and driver-memory's fallback generateSql enter through the same doors. No MCP tool calls the analytics service. /analytics/meta is a metadata listing, not a row read (flag 6). Pins: 30 cases = 2 strategies × 2 personas × 7, plus 2 fail-closed; every negative pin asserts the envelope and zero reads of the object; the controls include an object that merely has an internal field. Not pinned, observation only: an internal field in the order and timeDimensions positions (the shared resolver names both), and a positive control for a whitelist that grants list (settled by the derivation above).

One read inside a covered entry is not judged: answerDataset's dimension-label pass (resolveDimensionLabels → the plugin's fetchRecordLabels) reads a reference-class dimension's target object — id to display field through executeAggregate, RLS-scoped — without asking the target's exposure declaration, so a dataset over an exposed object with a lookup dimension into an apiEnabled: false object would render that object's display names. The data door's $expand has the identical gap, so "the same decision every other door judges" holds symmetrically; the dev measured no in-repo producer and disclosed it in the Acceptance notes. Outside this card's member positions: escalated in ③ (flag 7), not a FAIL ground.

5. Every caller, fail direction. RIGHT. Neither gate takes a user or a context; there is no system carve-out (stricter than the MCP stdio bridge, which stands down for a system context). A provider throw refuses 403 PERMISSION_DENIED, logged at error; the plugin bridge throws when no data engine with getObject is registered (dataEngine() filters on typeof getObject === 'function', so the optional call cannot silently answer undefined for a method-less engine). An undefined declaration is "nothing declared", the spec's default-open branch every door reads, and unknown objects are refused earlier by the existence gate. The one stand-down: a host constructing AnalyticsService by hand without getObjectDeclaration gets no gate and a one-time warning — the posture the read-admission and read-scope providers already take; the shipped plugin always wires it. Acceptable and disclosed.

6. Public surface. Additive only: AnalyticsServiceConfig.getObjectDeclaration? (optional) and the ObjectDeclarationProvider type reachable through it; api-exposure-door.ts is not re-exported from the package index. Spec: one value-only ledger row in the existing @objectstack/service-analytics entry; REGISTERED_ERROR_CODES is a de-duplicated set and is unchanged; no export, schema or generated artifact moves (check:generated green). No governed path in the file list (Governed Surface Queue Guard green).

7. The 405 whitelist facet ("bounded in-place widening"). RIGHT to include. apiExposureDenialReason returns two reasons; honouring only api-disabled would mean discarding method-not-allowed at this door alone — a second rule, which the card forbids. Same file, same decision, same envelope family; in-repo reach is three platform objects whose whitelist omits list, none in authored analytics; the FROM → TO names it.

② Semver level

  • .changeset/22634-analytics-exposure-gate.md: @objectstack/service-analytics: minor, ! in the summary, a **BREAKING** banner, the (narrowing) arm, FROM → TO per strategy and per facet, measured producers plus an explicit NOT MEASURED for deployed and cloud-held definitions, and exactly one adr-0087: not-required (no-migration-prescription) marker. RIGHT: a (narrowing) arm is BREAKING, and the launch-window convention (check-changeset-no-major.mjs, pre-GA) ships breaking as minor with the banner and the arm as carriers; no-migration-prescription holds because the body carries no rewrite instruction — the remedy is the declaration an author already wrote, and objectstack migrate meta has nothing to rewrite. Check Changeset, which hosts both gates, is green on the head.
  • .changeset/22634-spec-ledger-analytics-exposure-codes.md: @objectstack/spec: patch. RIGHT: the row ships in spec's published files, so a published-file change takes a changeset and never skip-changeset; value-only with no export or schema move is the patch floor. A separate file keeps the BREAKING narrative out of spec's CHANGELOG; within the fixed group the resulting bump is identical either way.
  • The Clause-② declaration reads no (narrowing) in the PR body and in the breaking changeset body. RIGHT: no new key lands on a published payload (the 405 envelope's effective set is the shape the data door already hands down), and the diff narrows an accept set.

③ Boundary flags

  1. Open question 1 (provenance of the two new stamp sites). Seat answered A: claim amendment 6095740822; spec-lane declaration VERIFIED on [PM seat] domain:spec — 🟢 os-project-manager · session_01S3aAf11JjbW1mSGL1EhfFj #6017, comment 6095744277 at 2026-10-10T08:37Z, ahead of the round-1 push. ANSWERED, A is right: B's premise is false for two of the three routes (the runtime dispatcher serves /analytics/query and /analytics/sql), and C would fork one declaration into two codes. Lint & Repo Gates is green on the head.
  2. Deviation: isApiExposed (named by the dispatch) does not exist on main. ANSWERED: right to use apiExposureDenialReason alone — the card names it, and the door needs the reason, not the boolean face canServeApiOperation.
  3. Deviation: the 405 facet. ANSWERED, right (①.7).
  4. Acceptance note: a plugin composed with no data engine refuses every analytics query. ANSWERED: that is the fail-closed the card asks for; every shipped composition registers the engine as data; the Dogfood gates are green.
  5. Acceptance note: registerDataset does not refuse a dataset over such an object. ANSWERED together with flag 6.
  6. Finding 3 — GET /analytics/meta lists a configured cube over an apiEnabled: false object. getMeta emits the cube's name and title and its members' names, types and titles — not the base object name, not rows — so the disclosure is bounded to the author's own naming. But an author can register such a cube, it lists, and every query then answers 404 with no authoring-time signal: a metadata-authoring trap under Prime Directive chore: version packages #10, which belongs on a card rather than only in Acceptance notes. ESCALATED to the seat: file one low-priority domain:services card pairing it with flag 5 (list-or-refuse at registration), or rule it accepted on security(analytics): the ad-hoc analytics query serves objects that declare apiEnabled: false and columns declared internal: true, which every other generic exit refuses or withholds #22634.
  7. Finding 4 — the dimension-label pass and the data door's $expand do not judge a lookup target's exposure. ESCALATED to the seat: a reproducible defect of the same declaration (existence and display names of an unexposed object's rows), two doors, no in-repo producer — file it as a sibling of security(data): the data door's filter and group-by positions do not honour a field's internal: true the way its row read does — detail withheld pending maintainer #22646 (one card, both arms), security class, graded by reach.
  8. Finding 5 — @objectstack/mcp stamps OBJECT_API_DISABLED through a constant outside check:error-code-provenance's declared patterns, with no mcp ledger row. ESCALATED to the seat: a ledger-provenance drift, not this PR's — file a tooling card for the spec lane (add the mcp row and bring the constant inside the pattern, or extend the pattern).
  9. Findings 1–2. VERIFIED filed as security(data): the data door's filter and group-by positions do not honour a field's internal: true the way its row read does — detail withheld pending maintainer #22646 (open, security, priority:p1, domain:engine, pm:queue, 2026-10-10T08:38Z), naming both positions and the originating report.
  10. PR-body hygiene. The opening "Named gap — one CI gate is red by design" callout and the Acceptance-notes bullet "red until the ledger row lands" are stale after round 1: the seat's append says exit 0 and the head's Lint & Repo Gates is green. Not a verdict matter; strike or annotate at the next body edit.

Implemented-by: claude/issue-22634-analytics-exposure-gate
Reviewed-by: session_013j5gkUCpqQiti4GgPqqmnt (contract-review subagent, CONTRACT_REVIEW_TIER)

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 10, 2026 09:37
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 10, 2026
Merged via the queue into main with commit 156ddfa Oct 10, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22634-analytics-exposure-gate branch October 10, 2026 10:04
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/l tests tooling

Projects

None yet

2 participants