Skip to content

feat(analytics): enforce analytics_cube.public and default it to visible - #20348

Draft
objectstack-fleet[bot] wants to merge 8 commits into
mainfrom
claude/issue-20282-analytics-cube-public-enforced
Draft

objectstack-fleet[bot] wants to merge 8 commits into
mainfrom
claude/issue-20282-analytics-cube-public-enforced

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Part of #20282

Clause-②: yes

Rework round 1

The order of record is seat comment 5861384124 on #20282; claim 5859909653 is unchanged. The open question was ruled A in-seat: no ADR-0087 semantic entry, and Clause-②: yes and minor stay as shipped. There is one new commit on top, 2cfa134c34, with no rebase and no force-push.

  1. Checklist text: docs/qa/platform-checklist/areas/dashboards.json, item dashboards.cube-query. It moves from revision 1 to 2 and gains a history entry. Three pieces of text were rewritten to what is true after this PR:
    • The meta clause's verify text: getMeta lists a cube only when its public is not false. A public: false cube is omitted and refused by query()/generateSql() with 404 CUBE_NOT_FOUND. showcase_delivery declares no public, so it is visible by default.
    • The showcase-cube source line no longer says public:false.
    • The getMeta source line no longer says it "returns all registry cubes".
    • The verdict is unchanged. Every clause, step and negative still holds, because showcase_delivery stays visible with the same four measures and four dimensions.
    • pnpm check:platform-checklist is OK (266 items; symbol anchors 624/642, 18 on the named residual).
  2. Measurement only, no fix: the inline-dataset name collision, at the public door. It reaches.
    • Boot: @objectstack/verify bootStack (the real Hono app, in process), with the analytics-admission-fixture stack on sqlite-wasm.
    • Two authored cubes, parsed through CubeSchema and passed as AnalyticsServicePlugin({ cubes }): open_summary (visible) and hidden_summary (public: false), both over admission_open.
    • Callers: A and B are two separate plain-member sign-ups.
    • Before, B calls GET /api/v1/analytics/meta and gets 200 listing open_summary "Open Summary (authored)" with measure open_summary.authored_total. B's POST /api/v1/analytics/query for that measure answers 200, authored_total: 2.
    • A is refused, and the cube is replaced anyway. A sends POST /api/v1/analytics/dataset/query with an inline dataset named open_summary over admission_walled, an object A has no grant on. The answer is 403 PERMISSION_DENIED. B's meta then answers 200 listing open_summary titled "inline by A" with only open_summary.hijack_cnt. B's query of authored_total answers 400 INVALID_FIELD ("…which object 'admission_walled' does not have. Valid measures: hijack_cnt"). queryDataset registers the compiled cube before its admission gate runs.
    • A is admitted. On a second boot, the same request over admission_open answers 200 and makes the same replacement. B's open_summary.hijack_cnt answers 200, and the count is B's own RLS scope.
    • It persists. 3 s later, after unrelated traffic, B still sees A's definition.
    • The hidden cube. B's query of hidden_summary answers 404 CUBE_NOT_FOUND (this PR's gate). A's inline dataset named hidden_summary then answers 200. B's meta now lists hidden_summary, with A's definition. The authored secret_total is gone (400 INVALID_FIELD); the hidden definition was replaced, never disclosed.
    • A restart restores it. A fresh kernel from the same config serves the authored cube again.
    • The replacement is process-wide: it crossed users. No response returned rows outside the caller's own RLS scope. A multi-tenant (cross-org) boot was not measured.
    • The scratch probe was not committed, and no process is left running.
  3. Gates re-derived at 2cfa134c34. dispatch-gates --commands derived the same 113-command set, and all of it was re-run on this head.
    • Reconciled with --ran: 112 run with exit 0, 1 NOT MEASURED, 0 unrun.
    • check:skill-examples and check:type-check-debt first exited 3 and passed once their build prerequisites were built. The type-check-debt re-measure is OK: 4 ledger entries, 53 raw errors, none above its record.
    • NOT MEASURED: check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET (34 workspace packages have no dist/ here; a whole-tree build for CI).

Stage 1 of the analytics-cube-semantics family: analytics_cube.public and its default. Triage verdict ENFORCE (5859510828, execution note 1), claim 5859909653. #20282 remains open for the later stages (format, granularities, refreshKey, descriptions, and the objectui picker sub-issue). This PR does not touch any of them.

What changes

  • Spec. CubeSchema.public defaults to true (it was false) and gains a .describe(). false hides the cube from the analytics API. It is visibility, not row security. The default change is declared in DEFAULT_CHANGES_BY_MAJOR (packages/spec/scripts/lib/default-changes.ts), which the authorable-defaults ratchet requires.
  • Reader. New module packages/services/service-analytics/src/cube-visibility.ts. isCubePublic is the one reader: a cube is exposed unless it declares public: false. The registry holds the input shape, so an omitted key reads as the schema default. cubeNotPublicError is the refusal.
  • Discovery. AnalyticsService#getMeta omits a hidden cube. getMeta(name) for a hidden cube answers [], the same answer as a name no cube has.
  • Query doors. AnalyticsService#assertCubePublic runs first in query() and in generateSql(). It runs before token resolution, cube inference, admission and every strategy, so the refusal leaves the registry untouched. The refusal is 404 CUBE_NOT_FOUND, with a message that says how to expose the cube. It is never an empty result.
  • Internal producers. inferCubeFromQuery, compileDataset and CubeRegistry.inferFromObject each wrote a literal public: false, the old default. They now write true. Without that, the ad-hoc KPI path and the dataset door would refuse the cubes they mint themselves (see the internal-caller census below).
  • Showcase. examples/app-showcase/src/data/analytics/showcase.cube.ts drops its public: false (evidence below).
  • Ledger. The public row in packages/spec/liveness/analytics_cube.json flips dead to live, citing cube-visibility.ts#isCubePublic, analytics-service.ts#getMeta and analytics-service.ts#assertCubePublic, with the CLI threading as producer. The README cell and state-counts.md (regenerated) now read analytics_cube live 18 / dead 9.
  • Generated projections, regenerated by their generators: authorable-defaults/data.json (by the build) and content/docs/references/data/analytics.mdx (gen:docs). state-counts.md comes from gen:liveness-counts. No file under skills/** or any other governed surface changed, so this PR is not Tier H.

Present state, measured before the change (base 4e0f72e8d2)

  • Declared: packages/spec/src/data/analytics.zod.ts, public: z.boolean().default(false) under an /** Access Control */ comment, with no describe.
  • Readers: none. git grep for cube.public or .public found no reader in packages/services/service-analytics/src or packages/drivers.
  • Doors that list or resolve an authored cube:
    • GET /api/v1/analytics/meta goes to getMeta.
    • POST /api/v1/analytics/query goes to query().
    • POST /api/v1/analytics/sql goes to generateSql().
    • The three routes above are served by the runtime domains/analytics.ts.
    • POST /api/v1/analytics/dataset/query goes to queryDataset, which uses DatasetExecutor and then query().
    • The strategies resolve only ctx.getCube(query.cube), the root cube. Joins reach objects, not cubes, so no second cube is resolved per query.
  • Authored cubes in the repo: exactly one writes public, examples/app-showcase/src/data/analytics/showcase.cube.ts:98 with public: false. No platform object or qa fixture authors a cube public value. Test fixtures restated public: false in 45 files: 44 in service-analytics, plus packages/runtime/src/cross-field-refusal-operand-withhold.test.ts.
  • Lit control: the new pin file run on the unfixed code (commit 99f0e9d296, test only) gave Tests 10 failed | 3 passed (13):
    • expected [ 'hidden_cube', 'visible_cube', …(2) ] to not include 'hidden_cube' (getMeta lists the hidden cube).
    • query(): promise resolved "{ rows: [ {} ], fields: [ { …(2) } ] }" instead of rejecting.
    • generateSql(): promise resolved "{ …(2) }" instead of rejecting.
    • The 3 passes are the controls: a visible cube, an omitted-key cube and a parsed cube are all answered.

The showcase decision, with evidence

The cube is meant to be seen and queried, so this PR drops the false rather than keeping the cube hidden:

  • examples/app-showcase/src/coverage.ts marks analyticsCubes demonstrated, "Served by the foundational analytics capability (/api/v1/analytics/*)".
  • The cube's own docblock says it exists "to show BOTH analytics surfaces … cubes feed the analytics service (/api/v1/analytics/*)".
  • The platform checklist item in docs/qa/platform-checklist/areas/dashboards.json runs GET /api/v1/analytics/meta?cube=showcase_delivery and POST /api/v1/analytics/query { cube: 'showcase_delivery', … }.

A hidden showcase cube would demonstrate a 404. It is pinned in examples/app-showcase/test/gap-fill.test.ts (DeliveryCube.public === true).

Internal callers of the same door

Only AnalyticsServicePlugin registers the analytics service in this repo. In-repo consumers are the runtime REST domain, the REST dataset door and service-analytics itself. packages/runtime/src/domains/mcp.ts and action-execution.ts call a different getMeta (the metadata protocol's).

Two internal paths reach query() with a cube they minted:

  • The ad-hoc inference path registers what it infers, so the next request resolves it from the registry.
  • queryDataset uses DatasetExecutor, then query(), on the dataset's compiled cube.

Both producers wrote the old default as a literal. Neither literal meant "hidden": if it had, enforcement would refuse the producer's own query. So they now write true. That keeps their behavior (visible and queryable, as before) and does not widen the door. No internal caller needs to reach a non-public cube, so there is no needs_decision.

Pins

packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts, 13 cases:

  • getMeta omits a hidden cube; getMeta(hidden) answers [].
  • query() and generateSql() refuse with { code: 'CUBE_NOT_FOUND', status: 404, cube }, and no strategy ran.
  • The refusal happens before the registry is touched.
  • A refusal is a rejection, not an empty result.
  • Controls: a visible cube, an omitted-key cube and a CubeSchema-parsed cube are all answered.
  • The three internal mints produce visible cubes: the ad-hoc path answers twice and is listed, and queryDataset answers.

packages/spec/src/data/analytics.test.ts pins the default (true) and an explicit false that parses and is kept.

Ablation

The reader was mutated at HEAD 33348d6ec3 so that it stops filtering.

  • Mutation. node scripts/ablation-replace.mjs --file packages/services/service-analytics/src/cube-visibility.ts --anchor 'return cube.public !== false;' --replacement 'return true;' (WRAP mode, with the lock-held test run as its child).
  • On-disk proof, from the tool. The anchor count went from 1 to 0, the replacement count from 0 to 1, and the blob from 44d9fcf712d5 to e144bb908748.
  • Why no rebuild was needed. The pin file imports the service through relative src paths (../analytics-service.js, ../cube-visibility.js), so no package exports and no dist/ sit on the subject's resolution path.
  • Red leg. src/__tests__/cube-public-visibility.test.ts gave Tests 6 failed | 7 passed (13). The six reds are exactly the two getMeta pins and the four door-refusal pins. The controls and the internal-producer cases stay green. The direction was red, as expected.
  • Restore. The blob after restore is 44d9fcf712d5, equal to the HEAD blob, and git diff HEAD is empty. Two earlier attempts timed out in the verify-lock queue (exit 99) and never ran the test; each of their restores was proven the same way.
  • Green leg, at the committed state 33348d6ec3. Tests 93 passed (93) across the pin file, analytics-service.test.ts, query-dataset.test.ts and cube-inference-gate.test.ts.

Changeset

.changeset/20282-analytics-cube-public-enforced.md bumps @objectstack/spec and @objectstack/service-analytics at minor. The reasons:

  • Clause-②: yes takes at least minor.
  • Both packages are in the same fixed group.
  • The service change is a new enforcement, not a fix to an existing behavior.

The changeset is not declared breaking and carries no ADR-0087 marker. It also records the upgrade notes: omitted key (no change), explicit false (now hidden), os compile artifacts that carry a materialized false (recompile), and the platform-minted cubes.

Local verification

Every reading below was taken at HEAD 33348d6ec3, a clean tree that includes a merge of origin/main at eea8787aa7, unless a line says otherwise.

  • @objectstack/service-analytics, full vitest run, at c9382ff256. Test Files 1 failed | 129 passed (130), Tests 1 failed | 3053 passed (3054).
    • The one failure was this PR's own new case, a dataset fixture with no dimensions. It is fixed in 33348d6ec3, which changes only that test file.
    • The 44 re-spelled fixture files are among the 129 that passed.
    • At 33348d6ec3 the pin run is 93 passed (93).
  • @objectstack/service-analytics typecheck (tsc --noEmit, which reaches the tests): exit 0 at c9382ff256.
  • @objectstack/spec, targeted.
    • src/data/analytics.test.ts, analytics-strictness-batchd.test.ts, src/api/analytics.test.ts, src/contracts/analytics-service.test.ts and src/kernel/metadata-type-schemas.test.ts gave Tests 227 passed (227).
    • The spec build is green, including the authorable-defaults ratchet, which now prints the declared data/Cube:public: false → true.
    • check:generated reports all 15 artifacts up to date.
    • check:liveness is green: analytics_cube 27 classified (live 18, dead 9).
  • packages/runtime/src/cross-field-refusal-operand-withhold.test.ts, against a rebuilt service-analytics dist: Tests 11 passed (11).
  • Lit control, on the unfixed code at 99f0e9d296. Tests 10 failed | 3 passed (13) (the readings are quoted above).
  • Gates. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 113 commands. All were run and recorded, then reconciled with --ran: 111 exited 0, 2 are NOT MEASURED, 0 are unrun.
    • NOT MEASURED: check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET, 64 workspace packages have no dist/ in this worktree, so this is a whole-tree build for CI.
    • NOT MEASURED: check:type-check-debt, reason: PREREQUISITE NOT MET, @objectstack/driver-turso has no built types.
    • check:skill-examples first exited 3, because the client packages were not built. It was re-run after building them and exited 0.
  • NOT MEASURED: examples/app-showcase/test/gap-fill.test.ts, reason: the showcase's dependency closure is not built here (Failed to resolve entry for package "@objectstack/connector-mcp", so the run never reached a test). The pinned fact itself was measured lock-free: evaluating src/data/analytics/showcase.cube.ts with tsx prints DeliveryCube.public = true. The file runs in CI.
  • NOT MEASURED locally, declared to CI: @objectstack/spec's full pnpm test and typecheck.
  • Unit-level only: no dev server was booted. The door behaviour is pinned at the service seam that the runtime /analytics/* routes call.

Acceptance notes

Observations, not filed:

  • MemoryAnalyticsService does not read public. @objectstack/driver-memory's standalone MemoryAnalyticsService#getMeta and #query ignore the key.
  • Stale checklist wording in docs/qa/platform-checklist/areas/dashboards.json: fixed in rework round 1 (item 1 above).
  • An inline dataset can replace an authored cube. It is pre-existing and independent of public. It was measured at the public door in rework round 1 (item 2 above). Not fixed here; the seat decides whether to file it.

Generated by Claude Code

…eta and every query door

Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Co-Authored-By: Claude <noreply@anthropic.com>
CubeSchema.public defaults to true (it was false while nothing read it).
service-analytics reads it: getMeta omits a cube declared public: false,
and query() / generateSql() refuse it with CUBE_NOT_FOUND / 404 before any
strategy runs. The three internal mints (inferCubeFromQuery, compileDataset,
CubeRegistry.inferFromObject) write the visible default, so the ad-hoc KPI
path and the dataset door keep answering. Test fixtures that restated the
old default are re-spelled public: true; the showcase cube, which is the
app's /analytics/* demonstration, drops its public: false. The liveness
row for public flips to live.

Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Co-Authored-By: Claude <noreply@anthropic.com>
…s projections

data/Cube:public false -> true is declared in DEFAULT_CHANGES_BY_MAJOR (the
authorable-defaults ratchet refuses an undeclared default move); the reference
page and the liveness state counts are regenerated by their generators.

Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Co-Authored-By: Claude <noreply@anthropic.com>
…clared dimension

Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 28, 2026
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-analytics, @objectstack/spec, touching 11 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/spec/authorable-defaults/data.json, packages/spec/liveness/README.md, packages/spec/liveness/analytics_cube.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/releases/v14.mdx (via generateSql (symbol, a method of class AnalyticsService))

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
  • 4 changed file(s) yielded no anchor (packages/spec/authorable-defaults/data.json, packages/spec/liveness/README.md, packages/spec/liveness/analytics_cube.json, …) — pages documenting those are invisible to this run
  • 1 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 — 136 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 d498113b505f7b29b5e2c554f3152145edeb3461 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 d498113b505f7b29b5e2c554f3152145edeb3461 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ced cube visibility

getMeta no longer returns every registry cube: it omits one declaring
public: false, and query()/generateSql() refuse it with 404 CUBE_NOT_FOUND.
The showcase cube no longer declares public: false. Text only; the item's
clauses and verdict are unchanged. Revision 2 with its history entry.

Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Co-Authored-By: Claude <noreply@anthropic.com>
The os-regen driver kept the branch's side of state-counts.md in the merge
of origin/main; os-regen-merge step 2 took main's side and this commit
regenerates it with gen:liveness-counts over the merged ledger
(analytics_cube live 18 / dead 9; total live 937, dead 165, 1117 rows).

Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Co-Authored-By: Claude <noreply@anthropic.com>

This branch has not been deployed

No deployments
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 protocol:data size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants