Skip to content

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

Merged
objectstack-fleet[bot] merged 22 commits into
mainfrom
claude/issue-20282-analytics-cube-public-enforced
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 22 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 (narrowing)

Rework round 2

The order of record is seat comment 5863061968 on #20282. It responds to the at-tier contract review 5863057917 on this PR, which recorded FAIL at f84cd184df. Claim 5859909653 is unchanged. There are new commits on top only, with no rebase, amend or force-push. The final head is 5ddea9580d.

  1. The declaration. The changeset and this body now read Clause-②: yes (narrowing), and the changeset carries a **BREAKING** sentence.
    • It names the refused class: a query or SQL dry run against a cube declared public: false, and every cube in an artifact built by os compile before this release.
    • It gives the remedy: delete public: false from any cube that is meant to be queried, and recompile.
    • It carries the ADR-0087 disposition registered analytics-cube-public-default-visible-enforced. The gate printed the six categories, and registered is the true one because this PR adds the entry.
    • check-adr-0087-registration passes: 1 declared-breaking changeset with a disposition. Levels stay minor.
  2. The D3 entry. packages/spec/src/migrations/entries/semantic/18.analytics-cube-public-default-visible-enforced.ts is a structured TODO with no tracker numbers in the author-shown fields.
    • It covers both holdings: an authored public: false now hides the cube and refuses its queries, and a pre-release compiled artifact must be recompiled.
    • registry.ts is regenerated, and check:migration-registry passes.
    • spec-changes.json and the upgrade guide do not project major-18 entries yet, so they did not move.
    • Probe. Base is origin/main 862b6ce869, which does not have this PR.
      • I compiled a one-object, one-cube app whose cube omits public with the base CLI's compile command. The command class was run directly, because the oclif dev dispatcher loads every command module and needs the whole CLI closure built.
      • The artifact carries "public": false on the cube.
      • I served those artifact cubes on head's built AnalyticsService: getMeta() returned [], and query() and generateSql() returned 404 CUBE_NOT_FOUND.
      • The same cube recompiled with head's schema has public: true: listed, and both doors answer.
    • Narrowing, declared. The head leg ran at the service seam that the /analytics/* routes call, not through an HTTP boot. The verify closure rebuild did not get the lock (queue timeouts).
    • Observation. The open-core os serve artifact boot never threads analyticsCubes into the analytics service (standalone-stack.ts does not return them), so on that path an artifact's cubes are absent either way. The entry is worded for a host that registers cubes from an artifact.
  3. Non-disclosure. A hidden cube's refusal is now cubeNotFoundError, the same function the unknown-name path throws: the same CUBE_NOT_FOUND / 404 and the same message.
    • The one shared message names both possibilities and still contains "is not a registered object", which the dataset door's missing-source sniffer reads.
    • Pinned. For query() and generateSql(), the same name hidden in one service and absent from another gives equal status, code, message, cube and own keys.
    • Ablation at ac5260a28b, via scripts/ablation-replace.mjs. The mutation made the hidden-path message differ: anchor 1→0, blob 1a1763ac7d04→7e5bc6dd03ab. The two identity pins went red and the other 13 stayed green (Tests 2 failed | 13 passed). After restore the blob is back to HEAD's and git diff HEAD is empty.
    • Docs. The cube-visibility.ts docblock and the changeset sentence no longer claim public: false withholds the definition. They say what the key does: the cube is left out of /analytics/meta, and queries and SQL generation against it are refused. The definition stays readable on the metadata door.
    • The ledger evidence anchor was renamed to #cubeNotFoundError.
  4. Stale texts. Each now says that cubes declared public: false are omitted:
    • content/docs/api/data-api.mdx (GET /analytics/meta);
    • content/docs/api/client-sdk.mdx (the analytics.meta comment);
    • the IAnalyticsService.getMeta TSDoc.
  5. Merges of origin/main. Three merges went through scripts/pm/os-regen-merge.sh: 5a6267f486, then 862b6ce869, then 15bf186f50. Each regenerated artifact was regenerated with its generator (gen:schema, gen:docs, gen:liveness-counts), never hand-resolved.
  6. Verification. Service-analytics, spec and ledger checks, then gates:
    • The service-analytics full suite at 036af03342 passed with one exception: 130 of 131 files and 3067 of 3069 tests. The two failures were main's observer baseline, fixed in 5ddea9580d.
    • Pins at 5ddea9580d: 5 files, 108 tests passed. They are cube-public-visibility, query-dataset-request-scope, analytics-service, query-dataset and cube-inference-gate.
    • Service-analytics typecheck passes. Spec analytics, migrations and contracts/analytics-service tests: 194 of 194.
    • The spec build is green, and its ratchet still prints data/Cube:public: false → true. check:generated (15 of 15 current), check:liveness (analytics_cube live 18 / dead 9) and check:migration-registry are green.
    • dispatch-gates --commands at 5ddea9580d derived 117 commands. All ran and were reconciled with --ran: 117 run, 0 NOT MEASURED, 0 unrun.
    • check:query-options-erasure and check:type-check-debt hit the 420 s runner cap on the loaded box and were re-run with a longer cap; both exited 0.
    • CI at 5ddea9580d: 35 check runs, 33 success, 2 skipped. Mergeable: clean.

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. cubeNotFoundError is the refusal, and it is shared with the unknown-cube path (rework round 2).
  • 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, byte-identical to the one an unknown cube name gets (rework round 2). Its one shared message names both possibilities. 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 (narrowing) is BREAKING, and ships as minor under the launch-window convention.
  • Both packages are in the same fixed group.
  • The service change is a new enforcement, not a fix to an existing behavior.

Since rework round 2, the changeset carries a **BREAKING** sentence and the ADR-0087 disposition registered analytics-cube-public-default-visible-enforced. 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:


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 15 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.

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

  • content/docs/api/data-api.mdx (via IAnalyticsService (symbol, a top-level interface), CUBE_NOT_FOUND (literal, a string literal in assertInferableCube; a string literal in cubeNotFoundError))
  • content/docs/data-modeling/analytics.mdx (via IAnalyticsService (symbol, a top-level interface))

⛔ 1 release-owned page(s) also 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
  • 4 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 40b315b03345e334069dd454aecaf7016adbea4f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 40b315b03345e334069dd454aecaf7016adbea4f

⚠️ 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 40b315b03345e334069dd454aecaf7016adbea4f → 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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 105/105 CONTRACT_REVIEW_TIER
Head-sha: f84cd184dff0644cc2aca0e631a7309370b85db1

① Derived judgments

  1. Blocking: the enforcement half is an accept-set narrowing declared as a plain widening. A query naming a cube that declares public: false is answered on base and refused 404 at head (assertCubePublic, analytics-service.ts:1356/2015). The repo's precedent for an enforcement-only narrowing, .changeset/rls-check-defaults-to-using.md (security: RowLevelSecurityPolicySchema.check is published as "defaults to USING clause if not specified", but the write check runs only policies that declare check, so a USING-only policy never gates an INSERT #19942), declares no (narrowing), carries a **BREAKING** sentence and one adr-0087: marker, and ships minor. This changeset carries the migration prescriptions (delete the line; recompile) but no arm, no banner, no marker, so check-adr-0087-registration never judged it and compiled release notes will not flag it. The showcase taught public: false (showcase.cube.ts:98 on base), so consumers who copied it break on upgrade. Smallest fix: in the changeset and PR body re-spell the line Clause-②: yes (narrowing), add a **BREAKING** sentence naming the refused class and remedy, add one adr-0087: marker (the gate prints the categories); levels stay minor.
  2. Blocking: a changeset sentence is not true. "What public: false withholds is the cube's own definition: its name, …" — cubeNotPublicError tells a caller who names the cube that it exists and "declares public: false" (cube-visibility.ts:53), while the same docblock says the shared code was chosen so as not to "tell a caller which names exist". Smallest fix: re-spell the sentence and docblock (name confirmed only to a caller who names it; measures, raw SQL and dimensions withheld), or make the message generic.
  3. Blocking: stale hand-written doc. content/docs/api/data-api.mdx:482 says GET /analytics/meta returns "metadata for all registered cubes" — false after this PR, the class the checklist rewrite already fixed. Smallest fix: "all registered cubes not declared public: false" (also IAnalyticsService.getMeta TSDoc "returns all if omitted", client-sdk.mdx:321).
  4. Doors. getMeta filters (:1985-1988); query()/generateSql() gate first, before token resolution, ensureCube, assertReadAdmitted, strategies. Dispatcher /analytics/query|meta|sql → those three (domains/analytics.ts:126,136,158); errorFromThrown keeps .status/.code, so 404 CUBE_NOT_FOUND reaches the wire. Dataset door (rest-server.ts:11319 → queryDataset → DatasetExecutor → service.query :1245/:1261) and the draft-preview branch evaluate only the compiled dataset cube (public: true). Explain = /analytics/sql = generateSql; NativeSQL and ObjectQL strategies resolve only ctx.getCube(query.cube); no result cache. MCP/AI tools: none name analytics. MemoryAnalyticsService: not constructed outside tests, only a fallbackService inside strategies after the gate; AnalyticsService#getMeta never consults it. Observation, not blocking: the metadata door lists cube definitions ungated — packages/metadata/src/plugin.ts:140 maps analyticsCubes to analytics_cube items (loop :1094) and domains/meta.ts serves getMetaItems to any authenticated caller. That is the schema surface, as Cube.dev's schema files are; a prune follow-up is the seat's call.
  5. Refusal. Same CUBE_NOT_FOUND/404 as the unknown-name refusal (:2544-2552); only the message differs. Leak: existence of a hidden cube under a guessed name, which /meta would not reveal; nothing of the definition. Minor; item 2 fixes the text.
  6. Default flip. Declared in DEFAULT_CHANGES_BY_MAJOR[17] (default-changes.ts:557), current major 17.4.0; authorable-defaults/data.json regenerated. Hidden after upgrade without an author-written false: YES, one class — a dist/objectstack.json built by a pre-flip os compile (serializes ObjectStackDefinitionSchema.safeParse output, compile.ts:357, so public: false is materialized) and served by os serve's artifact boot (serve.ts:1184). The changeset's "recompile" note is the only carrier; the seat's premise "unset cubes stay visible" does not cover it (supports item 1; a semantic registry entry stays the seat's ruling). Internal mints: all three write true; no other cube writer of public: outside tests.
  7. Showcase. coverage.ts:192 marks analyticsCubes demonstrated; the checklist item queries it; no test or doc relied on it hidden (no reader existed on base); gap-fill.test.ts pins public === true.
  8. Fixtures. 67 lines in 44 service-analytics files + 1 runtime file; every changed line is public: false → public: true (diff uniq), no other edits; no base test asserted hidden behaviour. Meaning unchanged.
  9. Ledger/docs/checklist. analytics_cube.json public row live, evidence cites cube-visibility.ts#isCubePublic, analytics-service.ts#getMeta, #assertCubePublic, #cubeNotPublicError, producer serve.ts#CAPABILITY_PROVIDERS + analytics-service.ts#registerAll (:922); Spec property liveness green. dashboards.cube-query rev 2 text is true. analytics.mdx row equals the .describe() text with default true. state-counts.md 18/9/27 and 937/165/1117 is current: main's only ledger move since the merge-base (view.json) is note-only.
  10. Changeset. Every other sentence checked true. Both packages minor (same fixed group). Clause-②: yes stands alone on its own line in the changeset and the PR body. CI at head: 35 runs, 33 success, 2 skipped, 0 failed.

② Semver level

minor on @objectstack/spec and @objectstack/service-analytics is right; the declaration is not. Per clause2-line.mjs: yes with no arm reads as a widening and takes at least minor (satisfied); yes (narrowing) is the reading for a diff that widens one surface and narrows another, is BREAKING, and still ships minor under the launch window (check-changeset-no-major.mjs), with the banner and ADR-0087 marker as the carriers. Widening: default false → true, .describe(), JSON-schema default. Narrowing: queries against a public: false cube go from answered to 404; pre-flip compiled artifacts go dark. Verdict: Clause-②: yes (narrowing), minor, banner, marker (item 1).

③ Boundary flags

Implemented-by: claude/issue-20282-analytics-cube-public-enforced
Reviewed-by: session_01QcAS3qiYYZNezaxZxaUdMV

VERDICT: FAIL

… merged tree

os-regen-merge took main's side of content/docs/references/data/analytics.mdx
and packages/spec/liveness/state-counts.md (both sides changed them); this
regenerates both from the merged source (gen:schema + gen:docs,
gen:liveness-counts). Against main each differs only by this PR's rows.

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

A cube declared public: false is now refused by query() and generateSql()
with cubeNotFoundError, the same CUBE_NOT_FOUND / 404 and the same message a
name that is neither a cube nor an object gets, so the refusal does not
confirm that a hidden cube exists. The one shared message names both
possibilities. The identity is pinned for both doors. The visibility
docblock, the ledger row and the two hand-written API pages now say only
what the key does: left out of /analytics/meta, queries and SQL refused.

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

The changeset now reads Clause-②: yes (narrowing), with a BREAKING sentence
naming the refused class (queries and SQL against a cube declared
public: false; pre-release os compile artifacts, which materialize the old
default) and its remedy. The ADR-0087 D3 semantic entry
analytics-cube-public-default-visible-enforced carries both holdings for an
upgrading author; the registry is regenerated (the major-18 entries are not
projected into spec-changes.json or the upgrade guide yet). The
IAnalyticsService.getMeta TSDoc says hidden cubes are omitted. Levels stay
minor.

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

registered analytics-cube-public-default-visible-enforced, the D3 entry this
PR adds.

Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 07:54
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Sep 28, 2026
os-regen-merge took main's side of packages/spec/liveness/state-counts.md
(both sides changed the shared total row); gen:liveness-counts re-derives it
from the merged ledger. Against main it differs only by this PR's
analytics_cube flip (live 18 / dead 9; total live 940 / dead 162).

Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…d never writes the shared registries (objectstack-ai#20380)

Fixes objectstack-ai#20356

Clause-②: no

## What this changes

`AnalyticsService.queryDataset` no longer writes the service-wide
`CubeRegistry` or the compiled-dataset registry. It used to register the
dataset's compiled cube under the dataset's name before running the
selection and before any admission was asked, so that name then meant
one request's definition for every later reader until restart.

- **Pure compile.** A new private `compile(dataset)` binds this
service's probes and registers nothing. `registerDataset` (the
configuration door: `AnalyticsServiceConfig.datasets` and embedders) is
`compile` + register, unchanged in behaviour. `queryDataset` calls only
`compile`.
- **A request scope for every name-keyed read.** A small internal
`CubeScope` (`getCube`, `getCompiledDataset`, `register`) is threaded
through the query path. The shared scope reads and writes the
registries. A `queryDataset` call gets a request scope: its own compiled
dataset answers its name, every other name reads the shared scope
read-only, and `register` writes only to the request scope.
- **One body for both.** `query()` is now `queryIn(sharedScope, …)`, and
the `DatasetExecutor` of a dataset call queries through a face whose
`query()` is `queryIn(requestScope, …)`. Every gate is the same code;
only the answer to "which cube does this name mean" differs.
- Comments that described `queryDataset` as a registration door are
corrected in `cube-registry.ts` and `dataset-compiler.ts`.

No new refusal is added. A dataset whose name matches a configured cube
is served from its own definition and no longer meets that cube (triage
note 3).

## Mechanism assumptions, measured

1. **Other registry writes.** `registerDataset` was not the only
request-time write reachable from `queryDataset`. `ensureCube`'s measure
augmentation also registered into the shared registry, through
`DatasetExecutor` → `query()`, when a selection named an undeclared
suffix measure. It now writes into the request scope, which is pinned.
The same two `ensureCube` writes (inference and augmentation) remain
request-time shared writes on the `/analytics/query` and
`/analytics/sql` doors. They are not the dataset door, and they are
reported, not changed (see Acceptance notes). The boot-time writes are
unchanged: `config.cubes` and `config.datasets` in the constructor.
2. **Name-keyed reads on the query path.** Each of these reads the
compiled cube by name, and each now resolves through the call's scope:
   - `ensureCube`'s existence and source-field gates;
- `queryObjects`, which is both the object-level admission set and the
read-scope set;
- the strategy context's `getCube` (both strategies' `canHandle`,
`execute` and `generateSql`);
- `getAllowedRelationships` (the NativeSQL join allowlist) and
`getDatasetScope` (both strategies), which read the compiled-dataset
registry.

ObjectQL's `resolveFkAttr` reads no registry, and
`queryCapabilities(cube)` is a config hook. `DatasetExecutor` itself
reads `compiled.cube` directly, but it issues `query({ cube: name })`,
which is why it needs the scoped face.
3. **Doors.** `POST /api/v1/analytics/dataset/query` (`rest-server.ts`)
calls `queryDataset`. `GET /api/v1/analytics/meta`
(`runtime/src/domains/analytics.ts`) calls `getMeta()`, which reads
`cubeRegistry.getAll()`, the shared registry.
4. **Hidden cube.** On `main` nothing reads `Cube.public`: three
producers write it and no reader exists in `service-analytics`,
`runtime` or `rest`. So "stays hidden from meta" is **NOT MEASURABLE on
this tree**. What is pinned is that the registry entry keeps the
author's `public: false` definition (unit test) and that member B's
whole `meta` answer equals B's baseline (route test). Once
`analytics_cube.public` enforcement lands, that same equality asserts
that the cube stays omitted.

## Tests (all on `15e21b98`)

- `src/__tests__/query-dataset-request-scope.test.ts` (new, both
strategy paths, 12 cases). A second caller's `getMeta()` and the exact
driver calls its queries of the configured cube and of the
boot-registered dataset are snapshotted before and after each of these
requests:
- a refused dataset under a configured cube's name (asserts
`PERMISSION_DENIED` / 403, and that the driver never saw the walled
object);
  - an admitted one, served from its own object;
  - a `public: false` name;
  - fresh names, one refused and one admitted with an augmented measure.

The control is the dataset registered at construction, which still
serves and keeps its compiled filter.
-
`packages/qa/dogfood/test/analytics-inline-dataset-isolation.dogfood.test.ts`
(new, `bootStack`, two sign-ups, `sqlite-wasm` + `memory`, 10 cases).
Member B's `meta`, B's authored-cube query and B's saved-dataset query
equal B's baseline after member A's requests: refused (403
`PERMISSION_DENIED`), admitted (200 from A's definition, A's own row
count), hidden-name (200, with no new refusal) and saved-name. The app's
saved dataset is the control.
- `dataset-i18n-label-resolution.test.ts`: the zh-CN meta pin relied on
`queryDataset` registering. It now registers through `registerDataset`
first, and its intent is kept: a zh-CN query leaves the published titles
in the source language.
- `pnpm --filter @objectstack/service-analytics typecheck` passes, and
`vitest run` gives 130 files / 3053 tests, all passing. `pnpm --filter
@objectstack/dogfood typecheck` passes, and the four analytics dogfood
files give 28 tests, all passing.

**Ablations** (each run with the fix committed first, mutation landing
proven on disk by `scripts/ablation-replace.mjs`, and restore proven by
blob equal to HEAD plus an empty `git diff HEAD`):

- **A, request-path registry write restored (unit).** `queryDataset`
calls `registerDataset` again. 10 of 12 go red. The two baselines stay
green.
- **B, admission set read from the shared registry (unit).** The refused
leg and the fresh-name leg go red, because the request resolved instead
of rejecting. This shows that a scope applied to the strategy but not to
the admission set would admit a read of the walled object.
- **A, route level through `dist/`.** Mutate, rebuild, then
`ablation-dist-preflight` finds the marker present in 2 built files. 8
of 10 go red. In the first red leg, B's authored-cube query answers `400
INVALID_FIELD` and B's `meta` lists the request's definition. The
restore leg rebuilds, the marker is absent from all 6 built files, the
tree is clean, and all 10 cases pass again.

**Gates.** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` over this branch's 7 changed
paths gives 66 commands. All 66 exited 0, and the `--ran` reconciliation
reports 66/66 with 0 NOT MEASURED. `check:dual-build-cjs-loads` first
exited 3 (PREREQUISITE NOT MET: 8 packages had no `dist/`). It passed
after those packages were built, all from the turbo cache. `eslint
--no-inline-config` over the 6 changed TS files, all in the config's
`**/*.{ts,…}` population, gives 6 files, 0 errors and 0 warnings. The
config enables no type-aware linting (no `parserOptions.project` or
`projectService`) and no cross-file rules, so this diff cannot change
the verdict on an untouched file. The repo-wide `pnpm lint` is left to
CI.

## Merge note for the later lander (PR objectstack-ai#20348, `analytics_cube.public`)

PR objectstack-ai#20348 adds `assertCubePublic(queryInput.cube)` at the top of
`query()`. It reads `this.cubeRegistry.get(name)`. Here, `query()`'s
body lives in `queryIn(scope, …)`, so a textual merge lands that line in
`queryIn`, still reading the shared registry. It should read
`scope.getCube(name)`. Read from the shared registry, a dataset request
named like a hidden cube would be refused `404 CUBE_NOT_FOUND` with the
hidden-cube message, which is a new refusal on this door and an
existence signal for hidden names. The dogfood HIDDEN leg asserts 200,
so a merge that leaves it on the shared registry goes red there.
`getMeta`'s visibility filter correctly stays on the shared registry.

## Acceptance notes

- The `/analytics/query` and `/analytics/sql` doors still write the
shared registry at request time, before admission (`ensureCube`
inference and augmentation). This is reported in the dev report as a
separate finding. The `CubeScope` seam added here is the natural place
to scope them, but that changes the ad-hoc door's documented registry
source and overlaps PR objectstack-ai#20348's `inferCubeFromQuery` edit, so it is not
done here.
- `strategies/native-sql-strategy.ts` (the join-allowlist comment near
the `getAllowedRelationships` refusal) still says `queryDataset`
registers the compiled dataset first. The invariant it states still
holds, through the request scope: the allowlist answers from the
compiled dataset and never falls through to the hook. The file is
outside this card's declared surface, so the wording is left for whoever
next touches it.
- The draft-preview branch still reads the pending seed rows before its
own admission check. This is a read, and nothing is returned on refusal.
It is unchanged.
- The one observable difference is stated in the changeset: a cube that
only a `queryDataset` call compiled is no longer listed by `getMeta()`
or queryable by name afterwards. No in-repo caller relies on that. A
search for runtime code querying a dataset name as a cube found none.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Sep 28, 2026
…alytics-cube-public-enforced

# Conflicts:
#	packages/spec/liveness/README.md
os-regen-merge took main's side of packages/spec/liveness/state-counts.md
(both sides changed the shared total row); gen:liveness-counts re-derives it
from the merged ledger. Against main it differs only by this PR's
analytics_cube flip (live 18 / dead 9; total live 940 / dead 148). The
liveness README conflict in the merge kept main's connector row and this
PR's analytics_cube row.

Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit f2c7eef Sep 28, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20282-analytics-cube-public-enforced branch September 28, 2026 10:46
os-warren pushed a commit that referenced this pull request Sep 28, 2026
…s the retired registration

`cube-public-visibility.test.ts` asserted that an inferred cube is
registered with `public: true` and listed by `getMeta`. Under the
retirement it is answered on every request and never registered or
listed; the case now pins that. `inferCubeFromQuery` keeps its
`public: true` literal (moot, and the pending #20282 release note says it
is written) with a comment that no longer claims the cube is registered.

Claude-Session: https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs
Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…st scope, publishing an inferred cube only after admission (objectstack-ai#20407)

Part of objectstack-ai#20381 — scope items 1 and 2 (required). Scope item 3 stays open
on the card as a decision; see "What stays open" below.

Clause-②: no

## What this changes

`AnalyticsService.query()` (`POST /api/v1/analytics/query`) and
`generateSql()` (`POST /api/v1/analytics/sql`) ran `ensureCube` over the
SHARED cube scope, before `callCtx` asked the object-level read
admission. `ensureCube` records what it mints in the scope it is given,
so:

- a request refused `PERMISSION_DENIED` still left the cube it inferred
for the refused object in the service-wide registry, and so in every
member's `getMeta()`;
- a suffix measure a caller named on a registered cube (`FIELD_sum`,
`FIELD_count_distinct`, …) was appended to that cube for every later
reader, refused or admitted.

Both doors now run in the request `CubeScope` that PR objectstack-ai#20380 introduced
for `queryDataset` — the same `requestScope()`, generalised to take no
compiled dataset (no second mechanism):

- `ensureCube`'s inference and augmentation both land in the call's own
scope, and the admission, read scope and strategy read them from there.
- `ensureCube` now returns the cube it INFERRED (nothing on the
augmentation or declared paths). The ad-hoc doors hand it to
`publishInferredCube` AFTER `callCtx` has admitted the request: that is
"CubeRegistry source 3", kept as triage ruled, now written only for an
admitted request. First registration wins, so a name the registry gained
while the request was being admitted is never overwritten by an inferred
cube.
- An augmented cube is never published. The dataset door
(`scopedService` / `queryIn` without the flag) publishes nothing, as
before.

No refusal is added, and no code or status changes. Boot-time `cubes` /
`datasets` registration is untouched. No `packages/spec` change.

**What `getMeta()` lists changes:** it no longer lists a cube inferred
for a refused request, and it no longer lists a suffix measure some
caller named on a registered cube. A cube inferred for an ADMITTED
request is still listed (source 3), which is the open question below.

## Measurements

All of these were taken on `origin/main` `df3ba164` plus this branch.

- **Premise: holds.** `queryIn` called `ensureCube(query, scope)` before
`callCtx` (the admission is in `callCtx`), and `generateSql` called
`ensureCube(query, this.sharedScope)` before `callCtx`. Pre-fix route
measurement (dist built from `df3ba164`): the new route pins are 12 of
24 red, and every negative leg fails on the observer-equality line after
its `403` envelope assertion passed. The new unit pins are 20 of 24 red.
- **PM assumption 1 (order is the whole defect): the naive order fix is
refuted; request-scope-then-publish is what works.** For a name with no
cube, `queryObjects` resolves the cube through the scope and returns an
EMPTY object set, so an admission asked before `ensureCube` admits
vacuously and never asks about the object. Ablation M0 below moved
`callCtx` ahead of `ensureCube` on `query()`. The refused request was
then SERVED on both strategies (`promise resolved "{ rows: [ { count: 5
} ] }" instead of rejecting`), and the admission provider was never
called for the object.
- **PM assumption 2 (augmentation never needs to be shared): holds.**
The full package suite is green with augmentation request-local (131
files, 3077 tests). No in-tree reader outside the call reads an
augmented cube. No existing test pinned the shared augmentation, so
nothing was rewritten. `dotted-measure-refusal.test.ts`'s warm-registry
case, which asserts the registered cube keeps `['count']`, stays green.
- **PM assumption 3 (what source 3 is used for).** In-tree readers of a
registered inferred cube:
  - `getMeta()`, which lists it;
- the next request's `ensureCube` and strategies, which resolve the name
to it and take the augmentation branch instead of re-inferring.
Re-inference would mint the same cube through the same gates;
  - the plugin's boot log (`plugin.ts:1287`, the count and names).
The client SDK exposes `analytics.meta()`. NOT MEASURED: Studio/objectui
consumption (no sibling checkout in this container). `getMeta()` has no
caller context (`IAnalyticsService.getMeta(cubeName?)` in
`packages/spec`; the runtime route passes none), so it lists every
registered cube to every caller.
- **PM assumption 4 (`generateSql` has the same admission): holds.**
Measured through the route: `/analytics/sql` answers the same `403
{"success":false,"error":{"code":"PERMISSION_DENIED","httpStatus":403}}`
as `/analytics/query`, from the shared `callCtx`. No refusal was added
to that door.
- **Scope item 3: it leaks.** Measured through the route on
`sqlite-wasm` and `memory`, on this branch's build. After the
administrator's ADMITTED ad-hoc query over the walled object, member B
lists that object's inferred cube in `GET /analytics/meta`, with the
administrator's measure and dimension member names. B's `GET /data` of
that object answers 403. B's `GET /meta/object/...` of the same object
answers 200 with its field list, so the object name and field names are
already readable to B there. What the listing adds is that an admitted
caller queried the object since boot, and which member names that caller
used. NOT MEASURED: a cross-org boot (the registry is process-wide).

## Tests (HEAD `16fc9f3b`)

-
`packages/services/service-analytics/src/__tests__/adhoc-query-request-scope.test.ts`
has 24 tests: both strategies × both doors, a second caller as the
observer, and whole-snapshot equality. It covers:
- REFUSED inferred (403 envelope, driver never ran, no registry entry);
  - REFUSED appended (the configured cube is still the authored object);
- ADMITTED appended (served with the caller's measure, the cube is still
the authored object);
- ADMITTED inferred (the ORDER pin: the admission provider reads the
registry when asked, and the inferred cube is not in it yet; afterwards
it is registered, source 3);
- the admission race (a registration made while the request is admitted
is kept);
- CONTROL: an admitted scalar metric works on a second request through
the published cube, and that request's suffix measure stays its own.
-
`packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts`
has 24 tests through the real route (`bootStack`, two sign-ups plus
admin), with one boot per driver × door so one door's leg cannot
pre-pollute the other's. Every refusal asserts status 403 plus
`error.code` `PERMISSION_DENIED` plus `error.httpStatus` 403. The legs:
- REFUSED inferred and REFUSED appended: B's `meta` and B's
configured-cube answer are unchanged;
  - ADMITTED appended: served with A's measure, and B is unchanged;
- CONTROL: an admitted scalar metric twice. B's answer is unchanged, and
every cube B listed before is listed unchanged;
- CONTROL: after the admin's admitted query over the walled object, B is
still refused on that door.
- `pnpm --filter @objectstack/service-analytics test`: 131 files, 3077
passed. `typecheck`: clean, and `tsc --listFiles` includes the new test.
- Dogfood analytics files (the new one, both PR objectstack-ai#20380 route pins,
`analytics-rls`, `analytics-label-scope`, `analytics-timezone`): 6
files, 54 passed. `pnpm --filter @objectstack/dogfood typecheck`: clean,
and it includes the new test.
- Gates: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack` over the actual changed paths on `16fc9f3b`
gives 66 commands, identical to the dispatch-time list. All 66 exit 0.
`--ran` reconciliation: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN. Two
commands needed a second run:
- `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit
3), because eight packages outside the dogfood closure had no `dist/`.
After a turbo build of those packages (41/41 cached), it exited 0.
- `check:type-check-debt` was first killed by my own batch timeout. Run
on its own, it exited 0.
- Lint, narrowed: `eslint --no-inline-config --format json` over the 3
changed TS files gives 3 files, 0 errors, 0 warnings. The population is
these 3 files; none is ignored by `eslint.config.mjs`. The invariance:
`eslint.config.mjs` never enables type-aware linting (its own header
states this), so this diff cannot move any untouched file's verdict. The
full `pnpm lint` is CI's.

## Ablations

Each ablation used `scripts/ablation-replace.mjs` in wrap mode, with the
fix committed first. The route legs rebuilt
`@objectstack/service-analytics` and ran
`scripts/ablation-dist-preflight.mjs` for both the present and the
`--absent` readings.

| leg | mutation | unit (24) | route (24) |
|---|---|---|---|
| M0 | `callCtx` moved ahead of `ensureCube` in `queryIn` (the naive
order fix) | 6 red: refused inferred SERVED; admission never asked | — |
| M1 | inferred cube also written to the shared registry inside
`ensureCube`, i.e. before admission | 8 red: refused inferred ×4, order
pin ×4 | 4 red: refused inferred, every driver × door |
| M2 | augmented cube also written to the shared registry | 10 red:
refused/admitted appended ×8, CONTROL ×2 | 8 red: refused/admitted
appended, every driver × door |
| M3 | first-registration-wins guard removed | 4 red: race pin ×4 | — |

- The dist marker was present in 2 built files for M1 and M2.
- Every restore was proven the same way: blob `95f2ef9a` equals the HEAD
blob, `git diff HEAD` is empty, and the rebuilt dist carries no marker
(`--absent` ✓, tree clean).
- M2's first attempt was refused by the tool: the replacement contained
the anchor, so the anchor count moved 1 → 1. Nothing was measured on
that attempt, and its restore was proven. M2 was re-run with a two-line
anchor.

## Overlap with PR objectstack-ai#20348

This PR is textually disjoint from objectstack-ai#20348's hunks except for the head of
`generateSql`, and it does not contradict objectstack-ai#20348:

- objectstack-ai#20348's `assertCubePublic(name, scope)` at the head of `queryIn` asks
the call's `scope`, which is now the request scope reading the shared
registry through. The answer is the same.
- Whoever lands second should make objectstack-ai#20348's `generateSql` gate ask the
call's `scope` (hoist `const scope = this.requestScope()` above it), so
the gate and the rest of the call ask one scope. The answer is identical
today.
- objectstack-ai#20348 mints inferred cubes `public: true` because an admitted
inferred cube stays registered, and it still does here.

## What stays open on objectstack-ai#20381

Scope item 3. An admitted inferred cube is listed by `getMeta()` to
every member, including members the object-level admission refuses for
that object. Closing that needs a door-shape choice:

- retire source 3;
- register the cube but leave it out of the listing;
- a caller-aware `getMeta` (a `packages/spec` contract change plus the
runtime route);
- rule it no leak.

Triage reserved that choice, so it goes back as `needs_decision` with
the four-axis analysis. Whichever option is chosen, it is a small
follow-up on top of this PR.

## Acceptance notes

- A request that is admitted and then refused by the STRATEGY (for
example the ObjectQL decline of a cross-object filter) still publishes
its inferred cube, as before. Publication follows admission, per triage
item 1. `infer-cube-relation-traversal.test.ts` ("mints the identical
cube for both spellings") reads that cube through `getMeta` and stays
green. This falls inside the item-3 decision space.
- `cube-registry.ts`'s class doc still describes source 3 without the
admission ordering. It is accurate, but less specific than
`analytics-service.ts` now is. It was left untouched to stay inside the
card's file surface.
- The inference branch still logs its `auto-inferred a minimal cube`
line (at `warn` for grouped queries) before admission. This is a
server-side log only, unchanged.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…no request writes the shared cube registry (objectstack-ai#20381) (objectstack-ai#20433)

Fixes objectstack-ai#20381
Clause-②: no

Item 3 of objectstack-ai#20381, under director ruling `5866558247` (letter A,
maintainer 「同意」): registry source 3 is retired. Items 1–2 landed in PR
objectstack-ai#20407 (`50e273fd`), so this round completes the card.

## What changes

- The ad-hoc `query` and `sql` doors (`POST /api/v1/analytics/query`,
`POST /api/v1/analytics/sql`) no longer publish the cube `ensureCube`
infers for an ADMITTED request. That cube stays in the call's request
scope, the one PR objectstack-ai#20407 gave these doors, and it is dropped with the
call, like a measure appended to a configured cube. The next request for
the same name infers the cube again, through the same existence and
source-field gates, and gets the same answer.
- The shared `CubeRegistry`, and therefore `getMeta()` and `GET
/api/v1/analytics/meta`, is now written by configuration only: manifest
cubes (`AnalyticsServiceConfig.cubes`) and `registerDataset` datasets.
`/analytics/meta` lists the authored vocabulary, whatever traffic the
server has seen since boot.
- `publishInferredCube` had no caller left and is removed, and
`ensureCube` returns `void` again. The `CubeRegistry` class docblock now
lists the two configuration sources and states that no request writes
the registry. The `CubeScope`, `queryIn`, `requestScope`, `ensureCube`
and objectstack-ai#5918 comments in `analytics-service.ts` no longer describe the
publication.
- No refusal, code or status changes. There is no `packages/spec`
change, no visibility marker and no caller-aware `getMeta`; options B, C
and D are not taken.

Landing point, as dispatched:
`packages/services/service-analytics/src/analytics-service.ts` (the
producer of the write) and `cube-registry.ts` (docblock only).

## Tests: re-observed, not deleted

On the fix commit, 36 cases in six service-analytics test files went
red; each of those files read an inferred cube back through `getMeta` or
the shared registry. PR objectstack-ai#20348, which landed while this round ran, added
a seventh such case. Each case is re-observed through a window that
still exists after A: the cube the request's own strategies are handed.
A probe strategy placed ahead of the built-in ones records
`ctx.getCube(query.cube)` and always declines, so the chain runs as it
would without the probe. Where an assertion's subject was the retired
registration itself, the assertion now pins its absence.

| File | Was | Now |
|---|---|---|
| `infer-cube-where-spelling-parity.test.ts` | dimension keys via
`getMeta('deal')` | the same keys, read from the request's cube |
| `infer-cube-relation-traversal.test.ts` | `run()` members via
`getMeta` | the request's cube |
| `dotted-measure-refusal.test.ts` | `run()` measures via `getMeta`;
block 2 case 1 asserted that the first query warmed the registry | the
request's cube; case 1 now pins that the first query warms nothing and
that the second, cold again, is still refused (the augmentation site
stays covered by the block's authored-cube case) |
| `analytics-service.test.ts` 'auto-infer' | `cubeRegistry.has('case')`
is true | the request was handed a cube named `case` and backed by
`case`; `has('case')` is false |
| `cube-inference-gate.test.ts` KPI case |
`cubeRegistry.get('crm_account')` is truthy | it is undefined; a second
request is served the same way and asks the existence gate again |
| `adhoc-query-request-scope.test.ts` (PR objectstack-ai#20407) | the admitted
inference "publishes after admission (source 3)"; the CONTROL case runs
"through the published cube" | the admitted inference is served from its
own cube, and both the registry and the observer's view are exactly
unchanged (the order pin is kept); the CONTROL case is now "a second
same-name request infers again and gets the same answer" |
| `cube-public-visibility.test.ts` (PR objectstack-ai#20348) | the ad-hoc KPI path's
inferred cube is registered `public: true` and listed | it is answered
on every request, and never registered or listed |

The route pin is
`packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts`:
`bootStack` with two sign-ups plus the administrator, on sqlite-wasm and
memory, through both doors.
- Both CONTROL legs are tightened from `arrayContaining` to exact
equality on member B's cube list. B's `meta` is also kept as the raw
response bytes.
- After the administrator's admitted ad-hoc query over the walled
object, B's `meta` is byte-identical and B's query of the configured
cube is unchanged. B's own query of that object is still refused with
the ADR-0112 envelope.
- An admitted scalar metric is re-inferred on a second request and
answers identically. Between the two requests, not even the asker's own
`meta` lists the name.
- A configured cube still serves: the baseline leg, and every
observation.

## Evidence (head `8214a5b6` unless stated)

- `pnpm --filter @objectstack/service-analytics typecheck` is clean.
`vitest run`: 132 files, 3093 passed. `tsc --listFiles` includes every
changed test file.
- `pnpm --filter @objectstack/dogfood typecheck` is clean, and
`--listFiles` includes the route pin. The six analytics dogfood files:
54 passed, on a `dist` rebuilt after merging `main`.
- **Ablation M1** puts the publication back at both ad-hoc sites, after
`callCtx`, as in `50e273fd`. It was applied with
`scripts/ablation-replace.mjs` (anchor 2 → 0) and predicted before
running.
- Unit, 7 files: 10 red / 132 green. The red cases are the
admitted-inference and CONTROL cases (4 + 2), auto-infer, the KPI gate
case, the objectstack-ai#20348 KPI case and the dotted warm case.
- Route, after rebuilding `service-analytics`, with
`ablation-dist-preflight` finding the marker in 2 dist files: 8 red / 16
green, both CONTROL legs on all four boots. For example: `expected [
'open_summary', …(3) ] to deeply equal [ 'open_summary', …(2) ]`, with
`+ "admission_walled"`.
- Restore: blob equals `HEAD`, `git diff HEAD` is empty, rebuilt, and
`--absent` preflight is green with a clean tree.
- On the pre-merge head `fccfc3e5` the same ablation gave 9/117 and
8/16.
- **Ablation M2**, on `fccfc3e5`, proves the probe window can fail. It
makes an array `where` seed no dimension, the pre-objectstack-ai#5353 shape. Across
parity and traversal: 16 red / 24 green. In parity, the 11 conjunction
table cases, ALONGSIDE and the two dotted array-versus-object cases went
red; both `$or` cases stayed green, as the file predicts. In traversal,
the two array-spelling mint cases went red. The restore was proven the
same way.
- **Gates**: `dispatch-gates --commands` derives 66 commands over the 12
changed paths. `--ran` reconciles 66 derived, 66 run, 0 NOT MEASURED and
0 UNRUN. 65 exit 0; `check:empty-changeset` exits 1 by design (see
Changesets).
- **Lint, narrowed**: eslint `--no-inline-config --format json` over the
10 changed `.ts` files reports 10 files, 0 errors and 0 warnings. The
population is those 10 files, none ignored. Invariance:
`eslint.config.mjs` never enables type-aware linting, so an untouched
file's verdict cannot move. The full `pnpm lint` is left to CI.

## Changesets

- New: `.changeset/20381-retire-inferred-cube-source.md`,
`@objectstack/service-analytics` `patch`, `Clause-②: no`.
- **A deliberate correction of a pending release note, for
confirmation:** `.changeset/20381-adhoc-cube-request-scope.md` was added
by PR objectstack-ai#20407 and is not yet released. It said that an admitted request's
inferred cube "still registers the cube it inferred, as before" and that
it "is still listed". This PR makes both sentences false, so they are
removed and replaced by a pointer to the new entry.
`check:empty-changeset` refuses any PR that modifies a changeset it did
not add. For this DELIBERATE CORRECTION class it stays red by design
(ruling D on objectstack-ai#17712), and it needs a person's confirmation here.
Restoring the file from `50e273fd` would clear the gate, but the release
would then ship both statements in one CHANGELOG.

## Overlap with PR objectstack-ai#20348

PR objectstack-ai#20348 landed first (`f2c7eef5`), and `main` is merged here
(`dfd185d7`) with no textual conflict.
- Its `public: true` on the inferred cube is moot under ruling A,
because no visibility verdict ever reads that cube. The literal is
**kept**: the pending note
`.changeset/20282-analytics-cube-public-enforced.md` says the inferred
cube "now writes `true`", and dropping the key would falsify a second
foreign changeset. Only its comment, which said the cube is registered,
is corrected.
- Its `generateSql` gate still asks `this.sharedScope` rather than the
call's scope. The answer is identical, because a fresh request scope
with no dataset reads through to the shared registry, so this is noted,
not changed.
- Its other changes are untouched.

## Acceptance notes

- Log frequency: for a GROUPED ad-hoc query over an object with no
configured cube, `ensureCube`'s `warn` ("No cube registered …;
auto-inferred a minimal cube …") used to fire once per name per process,
because the second request found the published cube. It now fires on
every such request; scalar metrics stay at `debug`. This was not
measured against real dashboard traffic, and it is noted, not changed:
the ruling adds no state.
- `content/docs/api/data-api.mdx`, in its `GET /analytics/meta` section,
says that a cube a query references "is lazily auto-inferred from that
query's shape". It does not claim the cube gets listed, but it could now
say that it does not. That file is outside this card's file surface.
- Per the ruling, the two unmeasured cases (a cross-org boot, and an
FLS-hidden field used as a dimension) cannot leak through `meta` for
inferred cubes once this lands. They stay as notes for the ADR-0106 D5
audit.
- The refusal tests that assert `cubeRegistry.get(...)` is undefined
after a rejected query (the three source-field gate files,
`cube-inference-gate`, `dotted-measure-refusal`) now hold by
construction for every request, not only for refused ones. They are left
as they are.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…the record key is the member's name (objectstack-ai#20300) (objectstack-ai#20458)

Fixes objectstack-ai#20300

Clause-②: no (narrowing)

Retires the inner `name` on analytics cube measures and dimensions
(`MetricSchema.name`, `DimensionSchema.name`). `measures` and
`dimensions` are records, and the record key was always the member's
identity: `GET /api/v1/analytics/meta` publishes every member as
`CUBE.KEY`, and every consumer resolves a member by indexing the bag
with its key. The inner copy was REQUIRED, read by nothing, and silently
ignored when it disagreed with its key.

ADR-0049 enforce-or-remove, by triage's verdict `5859547666` (RETIRE)
under the maintainer's criterion, verbatim: 「每族该问的是:主流平台有没有这个能力 —— 有 ⇒
补消费端(一次做对);没有 ⇒ 退役,而不是看仓里有没有人读」. Cube.dev and LookML key a member by its
declared name, with no second inner name that can disagree.

**Tier H.** The diff touches `skills/objectstack-ui/rules/dashboards.md`
(a deletion only; see Deviations 1). It lands on the maintainer's word,
then the seat lands it. 103 files, +1446 / -423 (1869 changed lines,
under the 5,000 line class).

## Patch round after objectstack-ai#20390 (head `f639af5f3`)

The sections below describe `c4771e604`. This head adds two commits and
nothing else:

- **`5ce26b0b2`** merges `origin/main` `75b2169243` through
`os-regen-merge.sh`. The one hand conflict,
`packages/spec/vitest.repo-tests.json`, was resolved by stacking both
entries. Main’s step-18 siblings and this PR’s entries are all present
in the four registries.
- **`f639af5f3`** stamps `retiredAfter: 17.4.0` on
`cube-member-inner-name-removed`. objectstack-ai#20390 (`e956924e1`) made the stamp
required on every `retiredFromLoadPath: true` conversion. This entry is
unpublished, so it takes the package label, as `view-list-tabs-removed`
and `action-aria-removed` do on `main`.

The net diff is 103 files, +1447 / −423: the stamp is the one added
line. CI is green on this head. At-tier record `5875291969`: PASS.

## What this head carries (`c4771e604`)

| surface | change |
| --- | --- |
| schema | `retiredKey()` tombstones on `MetricSchema.name` and
`DimensionSchema.name` (both `strictObject`s, the `action.aria`
posture). `tsc` types the key `never`, and the parse raises the
prescription at `measures.KEY.name` / `dimensions.KEY.name`. The
`measures` / `dimensions` describes now state that the record key IS the
member's name. |
| D2 | `cube-member-inner-name-removed` (protocol 18,
`retiredFromLoadPath`), chained into `step18.conversionIds` with a
rationale paragraph. It strips the inner `name` from every member of
every `analyticsCubes[]` entry, and its notice names the cube. |
| D3 | semantic entry `cube-member-inner-name-retired`: the judgement a
DISAGREEING value still owes its author (which spelling was meant). |
| registration | `RETIRED_KEYS_BY_MAJOR[18]` gains `data/Metric:name`
and `data/Dimension:name` (per-file entries, generated region). |
| ledger | both `analytics_cube.json` rows STAY `dead` (the tombstone
keeps the key in the walked shape) with a `REMOVED 2026-09-28` note and
a re-measured `verifiedAt`. The README row is updated; the counts do not
move. |
| producers | `dataset-compiler.ts` stops writing it (the triage line),
and so do the two untyped internal mints tsc cannot see
(`CubeRegistry.inferFromObject`, and `inferCubeFromQuery` /
`inferMeasure` in `analytics-service.ts`). |
| authors | the showcase cube (8 members), the `service-analytics`
README example (3), and the published `objectstack-ui` skill example (6)
|
| fixtures | about 300 member literals and map-built members across 84
test and fixture files in eight packages; the three existing step-18
cube conversion fixtures are trimmed so the whole-table replay stays
disjoint |
| pins |
`packages/spec/src/data/cube-member-inner-name-retirement.test.ts`
covers every door (schema, `/meta` binding, `defineCube`, `defineStack`
with its `STACK_SCHEMA_INVALID`/422 envelope, and a `@ts-expect-error`
tsc leg), the D2 legs (stored row, boot door with a lit control, a
disagreeing value, idempotence, load-path retirement), the registration,
and a tree-scoped structural absence pin over the declared five-root
radius. The flipped `analytics.test.ts` blocks (the snake_case pins on a
value nothing read) are now tombstone pins. |
| changeset | `@objectstack/spec` minor (BREAKING banner, FROM → TO, the
one-line fix, what an author sees, and the ADR-0087 `registered`
marker); `@objectstack/service-analytics` patch |

What an author who still writes it sees: `tsc` fails at the authoring
site. The parse refuses it with: "`measures.METRIC.name` was removed in
@objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it never had an
effect: the record key is the metric's name. … Delete the key. To rename
a metric, rename its key in `measures` — and every query, dashboard and
report that names `CUBE.KEY`. Run `os migrate meta --from 17` to list
the mechanical edits for existing sources; apply them by hand." A stored
or built cube heals at rehydration and at the artifact door.

## Zone 2, measured

- **A1 holds.** Zero reads of a member's inner `name` in non-test source
(`analytics-service.ts#getMeta` and `memory-analytics.ts#getMeta`
publish `CUBE.KEY`; `native-sql-strategy.ts#lookupMember` and
`memory-analytics.ts#resolveMeasure` / `#resolveDimension` index the bag
by key). Lit control: four reads of `measure.label` / `dimension.label`
in the same two projections. The one `measure.name` hit
(`dataset-compiler.ts:383`) is a `DatasetMeasure`, not a cube member.
objectui at pin `f8a9d0fb05`: no cube-member authoring. `CubeSchema` is
used only in `clientValidation.ts` (control: that hit resolves at the
same sha).
- **A2 holds, and is wider than the card.** Non-test producers:
`dataset-compiler.ts` (2 sites), `CubeRegistry.inferFromObject` (3) and
the ad-hoc mint in `analytics-service.ts` (4, plus `inferMeasure`'s 3
returns), the showcase, the README and the skill. Every one wrote the
name EQUAL to its key, so no producer writes a disagreeing value. Test
fixtures: 21 disagreed, all in `driver-memory` (e.g. `totalAmount: {
name: 'total_amount' }`), and every one was queried by its key
(`orders.totalAmount`). That is the trap, live in-repo. No
`platform-objects` or template authors any cube. Stored rows:
`analytics_cube` wraps as `analyticsCubes` at
`applyConversionsToStoredItem`, and the pin's stored-row leg replays it.
- **A3.** Worked on the merged cube contract (`public` enforced, objectstack-ai#20348
landed). Line numbers are from the merged tree.
- **A4.** Merged `origin/main` `6e3e5462c` (which carries objectstack-ai#20357's
step-18 appends) with `bash scripts/pm/os-regen-merge.sh`.
- The driverless merge-tree (a bare shared clone with no
`merge.os-regen.driver` registered) answered exit 0 with no conflicted
paths.
- The script's step 2 took main's side of
`content/docs/references/data/analytics.mdx`, which was regenerated from
the merged tree in its own commit (`e19628132`).
- After the merge: both sides' ids are present in both step-18 lists
(`view-list-tabs-removed` and `cube-member-inner-name-removed`) and in
the rationale.
- After the merge: `check:generated` reported all 15 artifacts current,
measured right after a spec build of the merged tree.

## Deviations

1. **The `skills/**` split was ordered, then withdrawn.** The seat
ordered the skill hunk split into a companion PR, and withdrew that on
this measurement:
- The example is an `os:check` block that `check:skill-examples`
type-checks inside `typecheck-consumers`, a member of the required
`TypeScript Type Check` aggregator.
- Putting one inner `name` back into the example with `ablation-replace`
(restored to the HEAD blob, `git diff HEAD` empty) gave exit 1:
`dashboards.md:450:15 error TS2322: Type 'string' is not assignable to
type 'undefined'`. So this PR without the hunk is red.
- A companion PR alone on `main` would be red too: at base `dbddf02c1`,
`MetricSchema.name` is a REQUIRED `z.string()`. That half is derived
from the schema, not built.
- No landing order is green, so the hunk stays here, as a pure deletion.
2. **File surface wider than the claim, same package and same defect
class.** `CubeRegistry.inferFromObject` and the `analytics-service.ts`
mints are untyped (a `Record` of `any`) producers that tsc cannot see;
A2 put every producer in scope. Fixture edits span `service-analytics`,
`driver-memory`, `spec`, `client`, `objectql`, `runtime`, `qa/dogfood`
and `qa/downstream-contract`.
3. **Route.** The `spec-property-retirement` skill's route table maps
`.strict()` to deletion plus a guidance map. I took the triage's
`retiredKey()` route instead, which `shared/retired-key.ts` documents
for closed shapes (strictly stronger than a guidance entry) and which
`action.aria` used this week. As a result the ledger rows stay, per the
card's acceptance. The `CubeJoinSchema` docblock line that said cube
shapes never take a tombstone is corrected.
4. **D2 strips a disagreeing value too.** Triage: "lossless when it
equals the key; a disagreeing value gets a D3 entry". The D3 entry
exists. The strip still removes a disagreeing value because the key
already won everywhere, so no answer changes, and leaving it would stop
the cube loading at the boot door. The notice prints both spellings
(`from: name "total_amount"`, `to: (removed; the record key
"totalAmount" is the name)`).
5. **`service-analytics` is graded `patch`.** Its members are filed
under the same keys, and every `/analytics/*` answer is unchanged.
`@objectstack/spec` carries `minor`: a published narrowing ships `minor`
in the launch window, and the changeset declares it as `Clause-②: no
(narrowing)` under its BREAKING banner.

## Tests (head `c4771e604` unless stated)

- **`@objectstack/spec`:**
- `test` 564 files / 16641 tests green (merged tree `e19628132`; spec
`src/` is unchanged since).
- `test:repo` 37 / 675 green (pre-merge `c1cae40df`). Post-merge, its
three tree-reading legs this diff owns were re-run green: the retirement
pin, `retired-key-migrate-sentence`, and `build-schemas-check-mode` (107
tests together with the pin).
- `typecheck` green (src, scripts, and the test layer under its
shrink-only ledger). `tsc -p tsconfig.test.json --listFilesOnly` lists
the new pin, so its `@ts-expect-error` legs are live.
- **`@objectstack/service-analytics`:** typecheck green, 132 files /
3093 tests green (`14cac89f4`).
- **`@objectstack/driver-memory`:** typecheck (which reaches the test
layer) green, 57 / 1374 green (`14cac89f4`). tsc found the two map-built
members there; the same shape was then swept in service-analytics and
runtime.
- **Touched test files in other packages:** `client` 7/7, `objectql`
`protocol-meta` 95/95, `runtime` `cross-field-refusal-operand-withhold`
11/11, `downstream-contract` `contract.test.ts` 13/13 plus typecheck
exit 0.
- **Reverse verification.** Putting `name: m.name` back in
`dataset-compiler.ts` via `ablation-replace` gave
`src/dataset-compiler.ts(673,7): error TS2322: Type 'string' is not
assignable to type 'undefined'` against the rebuilt spec `.d.ts`.
Restored: blob equals HEAD, `git diff HEAD` empty. The direction was the
predicted one (red).
- **Gates.** `dispatch-gates.mjs --commands` at `c4771e604` derives 126
families. All 126 were run and recorded, and `--ran` reports 0 UNRUN.
  - 123 exit 0.
- 2 exit 3, PREREQUISITE NOT MET: `check:dual-build-cjs-loads` and
`check:type-check-debt` (both need the whole-repo build).
- 1 exit 1: `check:platform-checklist`, inherited from `main` (see
Acceptance notes). Its inputs are byte-identical to `main`, and it is
not a per-PR CI gate.
- **Lint (a proven narrowing).**
- Scope: `eslint --no-inline-config --format json` over exactly the 95
changed code files returned 0 errors and 0 warnings.
- Population: read from `eslint.config.mjs` (`files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`); the file count comes from
the JSON output.
- Invariance: the config itself records that it never enables type-aware
linting (no `parserOptions.project`), so no untouched file's verdict can
move.

**NOT MEASURED** (CI runs these):
- `qa/dogfood`: the two touched dogfood tests and
`expression-conformance`. The package does not resolve
`@objectstack/verify` unbuilt, so no test ran. Static reading: 0
`analytics.zod` references in that ledger, against 11 `.zod.ts`
references as the control.
- The showcase `typecheck`: 7 TS2307 for unbuilt connectors and plugins,
and 0 diagnostics in `showcase.cube.ts`.
- `downstream-contract`'s `consumer-specifier-ledger` needs
`@objectstack/cli` built.
- The two exit-3 gates above.

## Skills readings

`skills/objectstack-ui/rules/dashboards.md` goes 468 → 468 lines. The
whole package (every `SKILL.md`) goes 4404 → 4404. Against base the hunk
is 6 lines modified and nothing added: each change deletes a `name:
'KEY',` fragment.

## Acceptance notes (noted, not filed)

- `check:platform-checklist` is red on `main` at `3cf644938`:
- The failure: `areas/identity-auth.json` anchors
`plugin-auth/src/auth-plugin.ts#twoFactor`, and `7d6308895` (objectstack-ai#20429)
turned that line-start key into an inline nested object key (`plugins: {
twoFactor: true }`), which the shared resolver reads as absent by
design.
- It is independent of this diff: both files are byte-identical to
`main`.
- The gate is run by hand, not per PR. Carrier: the next PR to touch
`identity-auth.json` or `auth-plugin.ts`, or the checklist owner.
- The inner `name` carried a snake_case regex. The record key never had
one, and it legitimately takes camelCase and dotted spellings in-repo
(`driver-memory` fixtures; `'owner.amount_sum'` in
`dotted-measure-refusal.test.ts`). Nothing is enforced on the key today;
this is an observation, not a change here.
- The absence pin states its blind spot: members built under computed
keys (`Object.fromEntries(… { name: n, … })`). tsc found the typed ones
in `driver-memory`, and the rest of that shape was swept by an AST scan
(object literals holding `name`, `sql` and `type`). What remains is only
this pin's own refusal specimens and the schema shape.

## 维护者速读(草稿)

**改了什么**:分析立方体(cube)的度量与维度不再接受内部 `name` 字段;成员的名字就是它在 `measures` /
`dimensions` 里的键。写了 `name`
会在编译期和解析期被明确拒绝,并给出迁移提示。已存储的立方体在加载时自动去掉该字段,照常可用。

**为什么改**:这个字段从来没有任何代码读取,系统一律按键识别成员;当 `name`
与键不一致时,作者写的值被静默忽略。Cube.dev、LookML 等主流方案也只有一个名字。按您「主流平台有没有这个能力」的判据,判定退役。

**风险与代价(含回滚)**:对外行为不变 —— `/analytics/meta` 与查询接口的成员名仍是 `立方体.键`。仍写 `name`
的作者源码需要删除该字段(`os migrate meta --from 17` 列出改动)。回滚:还原本 PR 即可,无数据迁移需要撤销。本
PR 同时改了一个对外发布的 skill 示例(仅删除 `name`),因此需要您的批准。

**席位意见**:

**你要做的**:审阅后批准(Approve)本 PR。

Line 3 and Deviation 5 were amended by the `domain:spec` seat 1
(`session_01B3TqpoQbTAfG7G74GMDWNW`) before the at-tier review: this
diff widens no accept set and adds no export, so `Clause-②` is `no
(narrowing)`, as most retirements of this family on `main` declare.
`20323-action-aria-removed.md` declared `yes`; the definition in
`clause2-line.mjs` decides, not the precedent. The changeset line moved
with it in `2252e5728`, and the claim on objectstack-ai#20300 was amended in place.

The patch-round section above was added by the same seat after the
at-tier record `5875291969`.

---------

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants