Skip to content

fix(spec)!: analytics query doors refuse an empty cube name with 400 VALIDATION_FAILED, not 500 - #27691

Merged
objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-27505-analytics-empty-cube
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-27505-analytics-empty-cube

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by 田騏 · project thread

Fixes #27505
Clause-②: no (narrowing)
Metadata-protocol: narrowed

Fable review: owed — the account's Fable quota was exhausted at 2026-10-29 ~17:05Z; the seat runs it when the quota returns.

维护者速读

  • 改了什么:analytics 查询请求体的 cube 改为必填且非空(.min(1),一句带修法的拒收文案);POST /analytics/query、/analytics/sql 收到空 cube 名时在入口直接答 400 VALIDATION_FAILED,不再进引擎;service-analytics 里 query / generateSql 的两处裸 Error 改为同一个类型化拒收(400 VALIDATION_FAILED),进程内调用方拿到与 HTTP 入口相同的答案。
  • 为什么改:空 cube 名原先答 500 INTERNAL_ERROR,监控和调用方都当成服务端故障,会告警、会重试;同样的错误把键省掉却答 400。
  • 风险与回滚:这是一次收窄(BREAKING,changeset 为 minor,ADR-0087 已登记语义迁移条目);只影响发送 cube: "" 的调用方。附带一处可见变化:analytics 入口对缺失必填键的 fields[].code 从 invalid_type 改为 ADR-0114 的 required(状态码与 error.code 不变)。回滚 = revert 本 PR。
  • 先看:① packages/spec/src/api/analytics.zod.ts 的 cube 成员;② packages/services/service-analytics/src/cube-name-refusal.ts;③ packages/runtime/src/domains/analytics.ts 中把 body 传给 fieldsFromZodIssues 的那一行。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/analytics-empty-cube-refusal.test.ts,预期 Tests 10 passed (10):两个入口对空 cube 都答 400 VALIDATION_FAILED,且 service 未被调用。
  • 需要你做的:无,席位落地(落地前席位补 Fable 复核与 ## Contract review 记录)。

What changes

  • @objectstack/spec — AnalyticsQueryRequestSchema.cube becomes z.string({ error: ANALYTICS_CUBE_NAME_REQUIRED }).min(1, ANALYTICS_CUBE_NAME_REQUIRED) (JSON Schema minLength: 1). ANALYTICS_CUBE_NAME_REQUIRED is a new export: one prescriptive sentence ("Name the cube to query: …") for every refusal of the slot (absent, non-string, empty). ADR-0087 disposition: a semantic migration entry, analytics-query-empty-cube-name-refused (no D2 conversion: a cube name is a request value, never stored metadata). Regenerated: api-surface/api.json, export-origins/api.json, content/docs/references/api/analytics.mdx.
  • @objectstack/service-analytics — the two plain Error('Cube name is required …') throws (queryIn, generateSql) become cubeNameRequiredError(cube): validationFailure from @objectstack/types with status: 400, the schema's sentence as message, and a fields[] entry built by running the value through the schema's own cube member and the one ADR-0114 D3 mapper, so the field code is the door's for the same value (required / min_length / invalid_type).
  • @objectstack/runtime — the analytics door now passes the body to fieldsFromZodIssues(issues, body) (as the /data doors do), so an absent required key reads required instead of Zod's invalid_type. This is the one line outside the claim's declared file surface; see Acceptance notes.
  • Whitespace-only " ": deliberately unchanged. .min(1) does not cover it and no trim rule is added; it is a name no cube carries and keeps its 404 CUBE_NOT_FOUND from cube resolution. Pinned on both the schema side and the service guard side.

Tests

  • packages/spec/src/api/analytics.test.ts: cube: "" gives one too_small issue at cube reading the prescription; a missing cube keeps invalid_type with the same sentence; "o" and " " still parse; JSON Schema carries minLength: 1.
  • packages/runtime/src/analytics-empty-cube-refusal.test.ts (new), both doors: cube: "" gives 400, error.code: VALIDATION_FAILED, fields: [{ field: 'cube', code: 'min_length' }], and the service is never called; the envelope parses against ApiErrorSchema; missing cube / missing measures / no body keep their 400 with code required; a named cube reaches the service with the body untouched.
  • packages/services/service-analytics/src/__tests__/analytics-service.test.ts: in-process query() / generateSql() with '' or no cube reject with { code: 'VALIDATION_FAILED', status: 400, name: 'ValidationError' } and the door's field entry; null reads invalid_type; whitespace is not this refusal. The old toThrow('Cube name is required') pin is replaced (pin sweep: no other repo pin on either old message outside driver-memory, which is out of scope, see below).
Package runs at head 427f5e9
  • pnpm --filter @objectstack/spec typecheck exit 0; … service-analytics typecheck exit 0; … runtime typecheck exit 0.
  • pnpm --filter @objectstack/service-analytics test: Test Files 196 passed (196), Tests 4739 passed, 282 skipped.
  • pnpm --filter @objectstack/spec test: Test Files 752 passed (752), Tests 22349 passed, 1 todo.
  • pnpm --filter @objectstack/runtime test: Test Files 478 passed (478), Tests 7794 passed, 19 skipped.
Ablation at head 427f5e9 (one-off, via scripts/ablation-replace.mjs, restored to the HEAD blob)
  • Baseline with the fix: spec analytics.test.ts 38/38, runtime analytics-empty-cube-refusal.test.ts 10/10, service analytics-service.test.ts 46/46.
  • Leg A, .min(1, ANALYTICS_CUBE_NAME_REQUIRED) deleted from the schema (anchor 1 → 0, blob 73d6676181 → a99fae0ade; spec and runtime read spec source through the alias): spec 2 failed / 36 passed (the empty-cube refusal and the minLength pin); runtime 4 failed / 6 passed (the empty-cube refusal and the envelope pin, on both doors). Missing-cube pins stay green, as expected: the type-level error is untouched. Restored: blob == HEAD, git diff HEAD empty.
  • Leg B, both throw cubeNameRequiredError(queryInput.cube); replaced with throw new Error('Cube name is required'); (anchor 2 → 0, blob 34d1d84686 → 2ea1302a68): service 5 failed / 41 passed (every typed-refusal pin; the whitespace pin stays green as expected). Restored: blob == HEAD, git diff HEAD empty.

Acceptance notes

  • File surface. The claim declared spec, the regenerated artifacts, analytics-service.ts, tests beside, and changesets. The diff also touches packages/runtime/src/domains/analytics.ts (one line: hand the body to the D3 mapper) and adds packages/runtime/src/analytics-empty-cube-refusal.test.ts. Reason: without the body, the door answers an absent cube with invalid_type and the in-process guard answers required, so the same condition would get two codes. As a result an absent measures and a request with no body now read required too. Status and error.code do not change. The seat decides whether that line stays.
  • Changeset moves @objectstack/runtime (minor) besides the two packages the order names, for the field-code change above.
  • Not covered: MemoryAnalyticsService (@objectstack/driver-memory) keeps its own plain Error('Cube name is required') for the same input; noted in the changeset, not changed here.

Verification

  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 110 commands at 427f5e9. All were run, with each exit code recorded before any pipe, 109 exited 0 on the sweep. pnpm check:type-check-debt exited 3 there (PREREQUISITE NOT MET: it did not get the verify lock while this branch's dogfood run held it) and exited 0 when re-run under the lock (0 ledger entries re-measured … none above its recorded number). --ran reconciliation: 110 derived, 110 run, 0 NOT-MEASURED (a derived zero: every entry carries its exit code).
  • Dogfood battery (the (narrowing) arm), head 427f5e9: pnpm --filter @objectstack/dogfood test gives Test Files 351 passed, 3 skipped (354); Tests 2825 passed, 20 skipped (2845); exit 0.
  • CI check-runs: see this PR's checks.

Generated by Claude Code

🤖 Generated with Claude Code

claude added 11 commits October 29, 2026 15:48
…VALIDATION_FAILED, not 500

AnalyticsQueryRequestSchema.cube takes .min(1) with one prescriptive sentence
(ANALYTICS_CUBE_NAME_REQUIRED); the two plain Error throws in service-analytics
(queryIn, generateSql) become the same typed refusal for in-process callers.
Semantic migration entry registered; tests at the schema, the door and the
service; changeset for both packages.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
… reference for the new export

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
…ch harness can measure

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
…both sides, and an absent non-string cube reads as absent

The analytics door hands the body to fieldsFromZodIssues so an absent cube
maps to the ADR-0114 required code, matching the engine's in-process guard;
the guard classifies only a string as too short. Review findings folded in.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
…ckage typecheck reads it

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
…ntry from the schema member and the one D3 mapper

No hand-rolled code split: the value runs through AnalyticsQueryRequestSchema.shape.cube
and fieldsFromZodIssues with the value beside it, so the entry is the door's for the same
value (required / min_length / invalid_type). The changeset names the runtime line and the
full required flip for every absent key of the analytics body. Delta-review findings folded in.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
…e cube slot, and the pins the changeset promises

The member carries a type-level error beside .min(1), so an absent or null cube
reads the same sentence at the door and in-process; the helper memoises the
member and treats an accepted value as a programming error; the door test pins
the required flip for measures and a bodiless request; runtime moves at minor.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
…e prose with the member's shape

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/runtime, @objectstack/service-analytics, @objectstack/spec, touching 9 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/export-origins/api.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

⛔ 3 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/v17/17-5.mdx (via generateSql (symbol, a method of class AnalyticsService))
  • content/docs/releases/v17/17-6.mdx (via /api/v1/analytics/query (route, a path literal in reason))

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

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/export-origins/api.json) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 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 — 172 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 a75ddba178a9196c87e60025409160a40322fac1 → packageMentionDocs.

Which tree this was computed on

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

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

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

…aves, not as a filed card

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

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract-review-claim: session_01L6bhgV4fMGJHSdSdx3u9mK · head a0b6210 · 2026-10-29T21:18Z


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#4 · marchtian · session_017ihL2fi9zfJhUTubyJYb6V

席位核对 · ACCEPT (lane part) · card #27505 · 2026-10-29T21:21Z

  • Shape: 12 files, +460 / −8 at head a0b621038a. AnalyticsQueryRequestSchema.cube takes .min(1, …) with a prescribing message (packages/spec/src/api/analytics.zod.ts) and an ADR-0087 semantic entry (18.analytics-query-empty-cube-name-refused.ts); the two plain Error throws in service-analytics (queryIn, generateSql) become one typed refusal (cube-name-refusal.ts); regenerated api-surface / export-origins / the analytics reference; tests in spec, service-analytics and runtime; changesets. Matches triage 6498803806 (schema narrowing + the two service throws, one PR, Clause-②: no (narrowing)).
  • This PR body is stale (the relay refuses a body write whose line 1 is the attribution marker). Current facts, superseding the body: head a0b621038a; CI on it 50 check-runs, 41 success, 9 skipped, 0 failed; gates 110 derived, 110 run, all exit 0 (--ran OK); ablation leg A (no .min(1)) reds spec 2 / runtime 4 / service 3 tests, leg B (plain Error back) reds service 5; the dogfood battery (owed by the ! title) passed; the Fable review line is superseded by the maintainer's order 「你没有fable了,使用 opus开发完之后等契约复审」 (dev finished on opus, claim amendment on analytics: POST /analytics/query and /analytics/sql answer an empty cube name with 500 INTERNAL_ERROR instead of 400 #27505). os-dev-report 6508587731.
  • Accepted deviation, cross-lane (domain:server file): packages/runtime/src/domains/analytics.ts passes the body to fieldsFromZodIssues so the existing ADR-0114 D3 split reports an absent cube as required on the door, the same field code the in-process refusal answers. Outside the claim's file surface; declared by the PR; status and error.code unchanged. Side effect for the contract reviewer to judge: a missing measures and a body-less request also move from invalid_type to required in fields[]. @objectstack/runtime changeset is minor.
  • Acceptance notes: driver-memory's MemoryAnalyticsService (memory-analytics.ts:1136, :1562) still throws a plain Error for a missing cube; the doors now refuse an empty cube before it, so only in-process callers reach it — dropped, below the filing gate (no public reach).
  • Owed before any landing stroke: the CONTRACT_REVIEW_TIER review (published schema analytics.zod.ts + the narrowing face). This seat cannot serve that tier now and hands it over below.

Contract-review-handoff: triage — CONTRACT_REVIEW_TIER unavailable (HTTP 429 "You've reached your Fable limit", 2026-10-29T17:05Z) · head a0b6210

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a0b621038aad223ccd675c51b20242c18c196040
Local-runs: none
Scope: packages/spec/src/api/analytics.zod.ts, packages/spec/src/migrations/entries/semantic/18.analytics-query-empty-cube-name-refused.ts + packages/runtime/src/domains/analytics.ts, packages/runtime/src/analytics-empty-cube-refusal.test.ts, packages/services/service-analytics/src/analytics-service.ts, packages/services/service-analytics/src/cube-name-refusal.ts, packages/services/service-analytics/src/tests/analytics-service.test.ts, packages/spec/src/api/analytics.test.ts, packages/spec/api-surface/api.json, packages/spec/export-origins/api.json, .changeset/27505-analytics-empty-cube.md (the narrowing floor: every non-prose path of the diff), and the cited code read to judge them: packages/spec/src/data/analytics.zod.ts (the base AnalyticsQuerySchema.cube), packages/spec/src/shared/lazy-schema.ts, packages/spec/src/api/zod-issues-to-fields.ts, packages/types/src/validation-failure.ts, packages/runtime/src/domains/data.ts, packages/spec/scripts/build-migration-registry.ts, packages/spec/src/migrations/types.ts, scripts/check-adr-0087-registration.mjs, docs/adr/0087-metadata-protocol-upgrade-contract.md, docs/adr/0114-field-level-error-code-catalog.md, packages/drivers/driver-memory/src/memory-analytics.ts

Inputs read: card #27505 (body and all six comments: triage 6495245828, retriage 6497972144, retriage answer 6498803806 — the ruling this PR implements, claim 6501118904, claim amendment 6506611960, os-dev-report 6508587731); PR #27691 body, file list (12 files, +460/-8) and the net diff against main from the files endpoint; 50 check-runs on the head: 41 success, 9 skipped, 0 failed, 0 pending — the ten required contexts all success except Lint UI, skipped by its path selector. Published spec read on origin/main 85663a57; the PR's tree read at the head sha.

① Derived judgments

  1. Accept set of AnalyticsQueryRequestSchema.cube narrows from z.string() (origin/main 85663a57, analytics.zod.ts:53) to z.string({ error: ANALYTICS_CUBE_NAME_REQUIRED }).min(1, ANALYTICS_CUBE_NAME_REQUIRED): the empty string is refused at both doors (POST /analytics/query, POST /analytics/sql; both run assertAnalyticsQueryBody, head analytics.ts:168 and :191). RIGHT — it is the remedy the ruling 6498803806 ordered (.min(1)), and the card's expected answer (400 VALIDATION_FAILED, same as a missing cube). Nothing else in the member's accept set moves: a one-character name and a whitespace-only name still parse (pinned in analytics.test.ts), and the base AnalyticsQuerySchema.cube (data/analytics.zod.ts:980, optional) is untouched, so in-process callers that build a query without a cube keep their type.
  2. New public export ANALYTICS_CUBE_NAME_REQUIRED (const) on @objectstack/spec/api, recorded in api-surface/api.json and export-origins/api.json (+1 row each). RIGHT as a surface change: generated from the built dist and the source, the ADR-0059 gate (TypeScript Type Check) is green. The constant is declared ahead of the lazySchema factory, which is correct under OS_EAGER_SCHEMAS=1 (the factory runs at module evaluation). It is a widening of the published surface — see ② for the declaration consequence.
  3. JSON Schema and docs: minLength: 1 on cube (pinned via z.toJSONSchema in analytics.test.ts) and the regenerated content/docs/references/api/analytics.mdx row. RIGHT. No other committed copy of the old Target cube name description exists outside src (the data/analytics.mdx hit is the untouched base schema's).
  4. Door field codes: both analytics doors now call fieldsFromZodIssues(parsed.error.issues, body) where main called it without the input (analytics.ts:72 on 85663a57). Consequence: an ABSENT required key reads required instead of invalid_type — for cube, for measures, and for a bodiless request ((body)); a present key of the wrong type still reads invalid_type; too_small on the string reads min_length. RIGHT — this is ADR-0114 D3 exactly (zod-issues-to-fields.ts:86-101: the parsed input walked to the path is the one discriminator; valueAtPath(...) === undefined is required), the /data door already passes input (data.ts:125), status and error.code are unchanged, and the changeset declares all three cases. The other doors (notifications.ts:221, automation.ts:1788) still pass no input; untouched here and not this card's.
  5. In-process error shape of AnalyticsService.query and generateSql: the two plain Error throws (85663a57 analytics-service.ts:2416 and :3362) become cubeNameRequiredError(queryInput.cube) — validationFailure (name: 'ValidationError', code: 'VALIDATION_FAILED', fields[]) with status = VALIDATION_FAILED_STATUS (400) set on it. RIGHT — the ruling's cross-domain exception named exactly these two throws; the shape is the one both dispatcher exits and resolveThrownHttpError already recognise; the fields[] entry is produced by running the value through the schema's own cube member (AnalyticsQueryRequestSchema.shape.cube.safeParse, which the Proxy's get trap serves as real.shape) and the one D3 mapper with { cube } as input, so absent reads required, '' reads min_length, null reads invalid_type — the door's codes for the same values, with no hand-rolled typeof table. The TypeError guard for an accepted name is unreachable from the two call sites (every falsy value fails the member) and correctly reads as a programmer error. No plain Cube name is required throw remains in packages/services at the head (git grep on a0b62103: 0 hits).
  6. Semantic migration entry analytics-query-empty-cube-name-refused registered under protocol 18 (PROTOCOL_VERSION is 18.0.0; the filename 18.analytics-query-empty-cube-name-refused.ts equals shardNameFor(18, id); the registry builder derives the major from the 18. prefix and lists the directory). RIGHT — ADR-0087 D3 (a judgment that cannot be expressed declaratively gets a structured TODO entry: surface, reason, acceptance criteria); no D2 conversion, correctly: a cube name is a request value, no sys_metadata shape carries one (no cube: member on any packages/spec/src/ui schema; a dashboard widget addresses a dataset, and DatasetSelection carries no cube — analytics-selection-door.ts). The precedent 18.analytics-query-window-non-negative-integer registers a request-time narrowing the same way. The id is new in the diff and resolves (0 hits on origin/main, 2 at the head: the entry and the changeset marker). The surface carries no backtick (build-upgrade-guide renders it in a code span); replacement and reason match the shape of the sibling entries.
  7. Whitespace-only cube: " " deliberately unchanged (parses; the engine answers CUBE_NOT_FOUND 404, as the card's own repro step 3 measured). RIGHT — .min(1) is the ruled rule and a trim rule would be a second one; pinned on the schema side and the guard side.
  8. MemoryAnalyticsService (@objectstack/driver-memory, memory-analytics.ts:1136 and :1562) keeps its plain Error('Cube name is required'). Declared in the changeset as not covered. RIGHT as a declaration: the HTTP doors refuse an empty or absent cube at the schema before any implementation runs, so the difference is in-process only; IAnalyticsService declares no error shape for it, so it is not a contract violation. Outside the ruling's exception; the seat decides whether a follow-up card is wanted.
  9. The entry's acceptance criterion "the repository carried no caller sending an empty cube at the time of the change" holds: git grep for cube: ''/cube: "" on origin/main 85663a57 returns 1 hit, http-dispatcher.test.ts:943, a GET /analytics/meta query parameter, not the request body. The one UI sender (ui/packages/data-objectstack/src/index.ts, payload = { cube: resource, ... }) posts the widget's bound resource name.
  10. Wire message for a missing cube changes from Zod's Invalid input: expected string, received undefined to the prescription sentence (the member's type-level error). Declared in the changeset. RIGHT: one sentence for every refusal of the slot, with the field code telling the cases apart; no test or consumer pinned the old wording (Test Core, Test UI, Dogfood all green).

② Semver level

Changeset .changeset/27505-analytics-empty-cube.md: @objectstack/spec: minor, @objectstack/service-analytics: minor, @objectstack/runtime: minor; fix(spec)!: summary; **BREAKING** banner; Clause-②: no (narrowing); Metadata-protocol: narrowed; Skills-docs: none — ...; one ADR-0087 marker adr-0087: registered analytics-query-empty-cube-name-refused. Check Changeset and Lint & Repo Gates (which runs check:adr-0087-registration) are green.

  • Level: RIGHT. A narrowed kind takes at least minor on every package the diff moves (AGENTS.md Post-Task step 3); major is refused pre-GA (ADR-0087, the 2026-09-13 amendment) and the banner plus the disposition are the breaking carriers. All three packages are moved by the diff, so minor on each is the rule's answer.
  • Disposition: RIGHT. registered with an id that is new in this diff and resolves in the registry at the head; the body carries the FROM (cube: "" accepted, 500) → TO (refused, 400 VALIDATION_FAILED, fields[0] = { field: cube, code: min_length }) mapping and the remedy sentence.
  • Metadata-protocol: narrowed: RIGHT — a value refused where it was accepted; the ADR-0087 gate's own docblock reads narrowed as "an accept-set narrowing of what an author writes against".
  • Clause-②: line: WRONG in its value, correct in its arm — should-fix. The diff adds a public export (ANALYTICS_CUBE_NAME_REQUIRED (const) on @objectstack/spec/api, api-surface/api.json +1 row), and the spec lane's own rule reads "enlarging the public surface, however small, is Clause ②" (lanes/spec.md:20; "declaring yes is never wrong", :21; "a wrong no is an auditable false declaration", landing-operations.md:10). The gate's fixtures spell the mixed case Clause-②: yes (narrowing). Consequence here is nil: the tier a yes owes is CONTRACT_REVIEW_TIER, which this record serves and whose ① item 2 judges the export; the level and the ADR-0087 reading (arm only) are unchanged. Fix: spell Clause-②: yes (narrowing) on both carriers (changeset body and PR body) in the same stroke that corrects the PR body (③).

③ Boundary flags

  • Deviation "runtime line outside the declared file surface" / open_question 1 (keep fieldsFromZodIssues(issues, body) in packages/runtime/src/domains/analytics.ts?): ANSWERED — A, keep it. Read in the cited code: ADR-0114 D3 names the parsed input as the only discriminator between absent and mistyped (zod-issues-to-fields.ts:86-101), the /data door already passes it (data.ts:125), and without it the one schema member would answer two codes for one condition (door invalid_type, in-process guard required) — which is the "second rule beside the schema" the ruling 6498803806 refused. The side effect (absent measures and a bodiless request also read required) is declared in the changeset and pinned in analytics-empty-cube-refusal.test.ts; status and error.code do not move. The surface breach itself is the dev's stop-on-breach duty, explained in the report; the design holds.
  • Deviation "PR body not updated" / open_question 2: ESCALATED to the seat — A, apply the prepared replacement body through a route that keeps line 1 (the relay refuses an issue_patch whose first non-empty line is not the seat signature). This record carries the current facts meanwhile: the readings on head a0b62103 are in os-dev-report 6508587731; the stored body's "Fable review: owed" line and the 需要你做的 bullet are superseded by the maintainer's order quoted in the claim amendment 6506611960 (keep the quotation verbatim). When it is applied, also add the Skills-docs: line the (narrowing) arm owes the PR body (pm-dispatch SKILL.md:313) — the changeset carries Skills-docs: none — ...; the PR body does not — and respell Clause-② per ②. Not a verdict condition: the body is not the tree.
  • Deviation "Clause-② spelling: claim no vs PR/changeset no (narrowing)": the arm belongs on the carriers (AGENTS.md step 3: (narrowing) is the BREAKING arm; the gate reads it). ANSWERED, with the value correction in ②.
  • Deviation "Fable review not run": the maintainer's order in the project thread is quoted in the claim amendment and the report; this record at CONTRACT_REVIEW_TIER is the review the PR waited for. ANSWERED.
  • out_of_scope_findings (driver-memory plain Error, in-process only, "not fileable"): ACCEPTED as noted — no contract text names the error shape, and the HTTP doors are closed by the schema; the seat may file a follow-up card at its discretion (see ① item 8).
  • No Local-runs: the check-runs on the head are the gate verdicts (0 failed), and the diff, the card and the published spec on origin/main answered every question read-only.

④ Design judgment

packages/spec/src/api/analytics.zod.ts — against ruling 6498803806 (.min(1) on AnalyticsQueryRequestSchema.cube, the generated artifacts refreshed, the two service throws made typed) and its first consumer, assertAnalyticsQueryBody in packages/runtime/src/domains/analytics.ts; the narrowing against the published spec on main (cube: z.string()): HOLDS. The accept set loses exactly the empty string. The member carries one sentence at both arms (type-level error, .min(1) message), so an absent, non-string or empty cube reads the prescription while the D3 code (required / invalid_type / min_length) keeps the cases apart; the sentence carries no tracker number. The constant is exported for the in-process guard to parse through the same member rather than word a second rule, and is declared before the lazySchema factory for the eager-schema path. .extend({...}).strict() keeps the retired-key tombstones and the unknown-key refusal of the base. Tried to break it: a whitespace-only name passes by design (404 from cube resolution, measured on the card); null fails as invalid_type (door and guard agree); the .describe() change is reflected in the regenerated mdx and no other committed artifact carried the old text; the Proxy serves .shape for the guard's member read; no consumer on origin/main sends cube: '' in a body. No must-fix, no should-fix.

packages/spec/src/migrations/entries/semantic/18.analytics-query-empty-cube-name-refused.ts — against ADR-0087 D3 and its first consumer, the registry builder (build-migration-registry.ts: major from the filename prefix, shardNameFor(18, id) equals the filename, entries listed from the directory) and the chain projections (spec-changes, upgrade guide — in-memory gates in TypeScript Type Check, green): HOLDS. All five required fields are present and read as the sibling entries do: surface names the member and the two doors with no backtick; replacement names a cube GET /analytics/meta lists or a registered object name and refuses to derive one; reason names the published spec 18.2.0 (the npm release, not the protocol major — ADR-0087's tombstone-dating rule), the measured 500, the schema fix and the in-process half; acceptanceCriteria gives a mechanical sweep (safeParse issue at the cube path, 400 with fields[0].field === "cube", service not called) and the preservation half (non-empty parses unchanged, missing keeps its 400). No conversionIds and no relevantWhen, correctly: it judges no mechanical edit and no stack key carries a cube name (the semantic-relevance roster stays at 21 protocol-18 entries). No must-fix, no should-fix.

Narrowing floor, the remaining non-prose paths, judged against the published spec on main: packages/runtime/src/domains/analytics.ts (one line, ① item 4) holds and makes the spec's own refusal real at the ADR-0114 code level; packages/services/service-analytics/src/cube-name-refusal.ts and the two call sites in analytics-service.ts (① item 5) hold — one refusal, status declared on it as the sibling cubeNotFoundError does, fields from the schema's member via the one D3 mapper; the three test files pin the refusal half (status, code, field entry, service not called) and the preservation half (named cube reaches the service with the body untouched; whitespace is not this refusal) on each side; api-surface/api.json and export-origins/api.json record the one new export. No capability a consumer held on main is taken away except sending the empty string, which answered 500 before.

Findings: must-fix — none. Should-fix — one, in ②: respell the Clause-② carriers yes (narrowing) for the new public export, together with the PR-body correction in ③.

Implemented-by: claude/issue-27505-analytics-empty-cube
Reviewed-by: session_01L6bhgV4fMGJHSdSdx3u9mK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 29, 2026 21:55
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 29, 2026 21:55
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit e0aed00 Oct 29, 2026
59 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-27505-analytics-empty-cube branch October 29, 2026 22:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

analytics: POST /analytics/query and /analytics/sql answer an empty cube name with 500 INTERNAL_ERROR instead of 400

1 participant