Skip to content

Commit e22158f

Browse files
os-trumpclaude
andauthored
fix(spec): widen AnalyticsResultResponseSchema and TriggerFlowResponseSchema data to producer-contract parity (#13184)
* fix(spec): widen AnalyticsResultResponseSchema and TriggerFlowResponseSchema data to producer-contract parity Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4 * chore(spec): regenerate reference docs, strictness ledger and import-surface baseline Tooling output of the schema widening, not hand-written: - `gen:docs` re-renders the analytics/automation-api reference pages with the newly declared members and the now-importable `AnalyticsResultResponse`. - `gen:strictness-ledger` moves the `api/` unknown-key site count 444 -> 448. - `--update-import-baseline` discharges the shrink-only ratchet entry `api/AnalyticsResultResponse — no type export`: the gap the card names is closed, and a stale line would stay available to excuse the next one. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a51da09 commit e22158f

14 files changed

Lines changed: 513 additions & 58 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/client": patch
4+
---
5+
6+
Widen two route response schemas to parity with the producer contracts their routes relay. `AnalyticsResultResponseSchema.data` now declares everything `AnalyticsResult` declares — `fields[].label` / `format` / `currency` / `percentScale` (the renderer chains) and `totals` (the marginal-aggregate channel) — and `TriggerFlowResponseSchema.data` now declares everything `AutomationResult` declares, including the paused screen-flow state (`status` / `runId` / `screen`), the closed `code` classification, the friendly terminal messages and the run `summary`. Both parities are pinned schema ≡ contract at compile time, so the two sources can no longer drift apart silently. `AnalyticsResultResponse` / `AnalyticsResultResponseParsed` are now exported: the schema previously had no nameable response type at all. This is an accept-set widening with zero wire change — every payload that parsed before still parses, and the served keys the schemas used to silently strip (a paused run's `runId` and `screen`, a measure's `label`) now survive a parse. The client SDK's `analytics.query` / `automation.trigger` docblocks are refreshed to record the new state; their bindings still target the producer contracts and are unchanged.

content/docs/references/api/analytics.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Provides endpoints for executing analytical queries and discovering metadata.
1818

1919
```typescript
2020
import { AnalyticsEndpoint, AnalyticsMetadataResponseSchema, AnalyticsQueryRequestSchema, AnalyticsResultResponseSchema, AnalyticsSqlResponseSchema, GetAnalyticsMetaRequestSchema } from '@objectstack/spec/api';
21-
import type { AnalyticsEndpoint, AnalyticsMetadataResponse, AnalyticsQueryRequest, AnalyticsSqlResponse, GetAnalyticsMetaRequest } from '@objectstack/spec/api';
21+
import type { AnalyticsEndpoint, AnalyticsMetadataResponse, AnalyticsQueryRequest, AnalyticsResultResponse, AnalyticsSqlResponse, GetAnalyticsMetaRequest } from '@objectstack/spec/api';
2222

2323
// Validate data
2424
const result = AnalyticsEndpoint.parse(data);
@@ -103,7 +103,7 @@ const result = AnalyticsEndpoint.parse(data);
103103
| **success** | `boolean` || Operation success status |
104104
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false |
105105
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
106-
| **data** | `{ rows: Record<string, any>[]; fields: object[]; sql?: string }` || |
106+
| **data** | `{ rows: Record<string, any>[]; fields: object[]; sql?: string; totals?: object[] }` || |
107107

108108
### Nested Shape: `AnalyticsResultResponse.error`
109109

@@ -123,8 +123,9 @@ const result = AnalyticsEndpoint.parse(data);
123123
| Property | Type | Required | Description |
124124
| :--- | :--- | :--- | :--- |
125125
| **rows** | `Record<string, any>[]` || Result rows |
126-
| **fields** | `{ name: string; type: string }[]` || Column metadata |
126+
| **fields** | `{ name: string; type: string; label?: string; format?: string; … }[]` || Column metadata |
127127
| **sql** | `string` | optional | Executed SQL (if debug enabled) |
128+
| **totals** | `{ dimensions: string[]; rows: Record<string, any>[] }[]` | optional | Marginal aggregates - one entry per requested totals grouping, in request order, each computed with the measure's true aggregate over the underlying data (never re-derived from bucketed values). The grand-total grouping yields a single dimensionless row. |
128129

129130

130131
---

content/docs/references/api/automation-api.mdx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,7 @@ const result = AutomationApiErrorCode.parse(data);
583583
| **success** | `boolean` || Operation success status |
584584
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false |
585585
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
586-
| **data** | `{ success: boolean; output?: any; error?: string; durationMs?: number }` || |
586+
| **data** | `{ success: boolean; output?: any; error?: string; durationMs?: number; … }` || |
587587

588588
### Nested Shape: `TriggerFlowResponse.error`
589589

@@ -606,6 +606,13 @@ const result = AutomationApiErrorCode.parse(data);
606606
| **output** | `any` | optional | Output data from the automation |
607607
| **error** | `string` | optional | Error message if execution failed |
608608
| **durationMs** | `number` | optional | Execution duration in milliseconds |
609+
| **code** | `Enum<'PERMISSION_DENIED' \| 'INVALID_SIGNAL' \| 'RUN_NOT_FOUND' \| 'STORE_UNAVAILABLE' \| …>` | optional | Machine-readable failure classification, set alongside `error` when the caller must distinguish WHY it failed. A closed union - the members and their transport mappings are documented on the contract (`AutomationResult.code`, contracts/automation-service.ts). |
610+
| **status** | `Enum<'completed' \| 'paused' \| 'failed'>` | optional | Lifecycle status. `paused` means the run suspended at a node and can be continued with the resume route. Absent or `completed`/`failed` means the run reached a terminal state. |
611+
| **runId** | `string` | optional | Run id - set when `status` is `paused`, so callers can resume it |
612+
| **screen** | `{ nodeId: string; title?: string; description?: string; fields: object[]; … }` | optional | The screen to render - set when the run paused at a `screen` node awaiting user input. The client collects values for `screen.fields` and resumes the run with them. |
613+
| **successMessage** | `string` | optional | Friendly terminal message copied from the flow definition on terminal success, so a screen-flow runner can show a meaningful toast |
614+
| **errorMessage** | `string` | optional | Friendly terminal message copied from the flow definition on failure |
615+
| **summary** | `{ selected: integer; acted: integer; skipped: integer; unmeasured?: integer; … }` | optional | What the run did - records selected / acted on, gate skips, per-node status. Set on a TERMINAL result (a paused run has not finished doing it yet). |
609616

610617

611618
---

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ directory rather than per file.
257257
| Dir | Sites |
258258
|---|---|
259259
| `ai/` | 77 |
260-
| `api/` | 444 |
260+
| `api/` | 448 |
261261
| `cloud/` | 83 |
262262
| `identity/` | 32 |
263263
| `integration/` | 10 |

packages/client/src/analytics-automation-json-erasure.test.ts

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,21 @@
5050
* highest-risk band (`return-type-precision.test.ts`, shape class 2). Hence one
5151
* driven case per method rather than a family-wide assumption.
5252
*
53-
* ## Two spec response schemas are NARROWER than their producer — measured here
53+
* ## Two spec response schemas WERE narrower than their producer — measured here
5454
*
55-
* `AnalyticsResultResponseSchema.data` and `TriggerFlowResponseSchema.data` are
56-
* stale projections of `AnalyticsResult` / `AutomationResult`: the fixtures
57-
* below serve keys those schemas do not declare (`fields[].label`, and a paused
58-
* run's `status` / `runId` / `screen`). That is why the two annotations bind the
59-
* PRODUCER's contract type rather than those two response types — binding them
60-
* would have been a false narrowing of exactly the kind #12034 removed. The
61-
* other two route schemas (`AnalyticsMetadataResponseSchema`,
62-
* `AnalyticsSqlResponseSchema`) DO agree with their producer's declared return,
63-
* and the annotations use them.
55+
* When #12104 landed, `AnalyticsResultResponseSchema.data` and
56+
* `TriggerFlowResponseSchema.data` were stale projections of
57+
* `AnalyticsResult` / `AutomationResult`: the fixtures below serve keys those
58+
* schemas did not then declare (`fields[].label`, and a paused run's
59+
* `status` / `runId` / `screen`). That is why the two annotations bind the
60+
* PRODUCER's contract type rather than those two response types — binding
61+
* them would have been a false narrowing of exactly the kind #12034 removed.
62+
* #13078 has since widened both schemas to parity (pinned schema ≡ contract
63+
* in `spec/api/analytics.test.ts` / `spec/api/automation-api.zod.test.ts`);
64+
* the annotations stay on the contracts, which are the source the routes
65+
* relay. The other two route schemas (`AnalyticsMetadataResponseSchema`,
66+
* `AnalyticsSqlResponseSchema`) agreed with their producer's declared return
67+
* all along, and the annotations use them.
6468
*
6569
* ---------------------------------------------------------------------------
6670
* Reverse verification, direction predicted BEFORE running
@@ -97,10 +101,12 @@ const CONTEXT = (): any => ({
97101

98102
/**
99103
* One cube with a LABELLED measure and dimension. The labels are load-bearing
100-
* rather than decorative: `AnalyticsResult.fields[].label` is a key
101-
* `AnalyticsResultResponseSchema.data.fields` does not declare, so serving it
102-
* is what proves that schema is a narrower projection than the producer's
103-
* contract — the measurement the annotation choice rests on.
104+
* rather than decorative: `AnalyticsResult.fields[].label` was a key
105+
* `AnalyticsResultResponseSchema.data.fields` did not declare when #12104
106+
* measured it, so serving it is what proved that schema a narrower projection
107+
* than the producer's contract — the measurement the annotation choice rests
108+
* on (#13078 has since widened the schema to parity; the served key is the
109+
* evidence either way).
104110
*/
105111
const ACCOUNT_CUBE: Cube = {
106112
name: 'crm_account',
@@ -119,8 +125,8 @@ const ROWS = [{ industry: 'tech', account_count: 3 }];
119125
/**
120126
* The ADR-0021 dataset the REST-served `queryDataset` route runs. Its dimension
121127
* carries a `label` on purpose: the dataset executor enriches
122-
* `AnalyticsResult.fields[].label` from it, which is the key
123-
* `AnalyticsResultResponseSchema.data.fields` does not declare.
128+
* `AnalyticsResult.fields[].label` from itthe key whose serving proved the
129+
* pre-#13078 schema narrower than the contract (see the cube above).
124130
*/
125131
const DATASET = {
126132
name: 'account_metrics',
@@ -160,7 +166,7 @@ const gate = defineActionDescriptor({
160166
/**
161167
* start → gate (pauses) → end. A PAUSED run is chosen deliberately: it is the
162168
* arm whose `AutomationResult` carries `status` / `runId` / `screen`, none of
163-
* which `TriggerFlowResponseSchema.data` declares.
169+
* which `TriggerFlowResponseSchema.data` declared before #13078 widened it.
164170
*/
165171
function realAutomation(): AutomationEngine {
166172
const engine = new AutomationEngine(
@@ -352,8 +358,10 @@ describe('#12104 — the four DISPATCHER-served methods resolve to the envelope,
352358
const body = await client.automation.trigger('approve_account', {});
353359

354360
expect(body.success).toBe(true);
355-
// The keys `TriggerFlowResponseSchema.data` does NOT declare, served by
356-
// the real engine: this is why the annotation binds `AutomationResult`.
361+
// The keys `TriggerFlowResponseSchema.data` did NOT declare before
362+
// #13078, served by the real engine: this measurement is why the
363+
// annotation binds `AutomationResult` (and, since #13078, why the
364+
// schema had to move to parity with it).
357365
expect(body.data.status).toBe('paused');
358366
expect(typeof body.data.runId).toBe('string');
359367
expect(body.data.screen?.title).toBe('Approve the account');
@@ -377,14 +385,15 @@ describe('#12104 — the REST-served method resolves to the BARE payload', () =>
377385
expect(Array.isArray(body.fields)).toBe(true);
378386
});
379387

380-
it('and it serves a `fields[].label` the analytics RESPONSE schema does not declare', async () => {
388+
it('and it serves the `fields[].label` that proved the pre-#13078 response schema narrower', async () => {
381389
// The measurement behind one of the two annotation choices. `query` and
382390
// `queryDataset` are the SAME contract return — `IAnalyticsService`
383391
// declares `Promise< AnalyticsResult >` for both — so a key the service
384-
// really emits is a key `AnalyticsResult` really carries. And
385-
// `AnalyticsResultResponseSchema.data.fields` declares only
386-
// `{ name, type }`, so binding that schema on `analytics.query` would
387-
// have been a FALSE narrowing of the contract the route relays.
392+
// really emits is a key `AnalyticsResult` really carries. When #12104
393+
// measured this, `AnalyticsResultResponseSchema.data.fields` declared
394+
// only `{ name, type }`, so binding that schema on `analytics.query`
395+
// would have been a FALSE narrowing of the contract the route relays;
396+
// #13078 has since widened the schema to parity with the contract.
388397
const { client } = producerBackedClient();
389398

390399
const body = await client.analytics.queryDataset({

packages/client/src/index.ts

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1492,15 +1492,19 @@ export class ObjectStackClient {
14921492
* The `data` member is `IAnalyticsService.query`'s declared return, relayed
14931493
* verbatim by the domain (`deps.success(await analyticsService.query(…))`).
14941494
*
1495-
* ⛔ NOT bound to `AnalyticsResultResponseSchema` (`@objectstack/spec/api`),
1496-
* which looks like the route's own response type: its
1497-
* `data.fields` declares `{ name, type }` only, while the contract this
1498-
* route relays also carries `label` / `format` / `currency` /
1499-
* `percentScale` and `totals` — measured on a real `AnalyticsService` in
1500-
* `analytics-automation-json-erasure.test.ts`. Binding it would have
1501-
* narrowed the declaration below what the producer serves, which is the
1502-
* bound-but-false shape #12034 paid to remove. (The schema's own drift is
1503-
* a spec-side question, filed separately.)
1495+
* Deliberately bound to the CONTRACT, not to `AnalyticsResultResponse`
1496+
* (`@objectstack/spec/api`). When #12104 wrote this annotation the schema
1497+
* was a stale projection — its `data.fields` declared `{ name, type }`
1498+
* only, while the contract this route relays also carries `label` /
1499+
* `format` / `currency` / `percentScale` and `totals` (measured on a real
1500+
* `AnalyticsService` in `analytics-automation-json-erasure.test.ts`), so
1501+
* binding it would have been the bound-but-false shape #12034 paid to
1502+
* remove. #13078 has since brought the schema to parity (pinned
1503+
* schema ≡ contract in `spec/api/analytics.test.ts`), but the binding
1504+
* stays on the producer's contract on purpose: the contract is what the
1505+
* route relays, and the schema is its transcription — annotating the
1506+
* source rather than the copy is what keeps this method immune to the
1507+
* transcription drifting again.
15041508
*/
15051509
query: async (payload: any): Promise<BaseResponse & { data: AnalyticsResult }> => {
15061510
const route = this.getRoute('analytics');
@@ -3806,14 +3810,21 @@ export class ObjectStackClient {
38063810
* therefore resolves to the `AutomationResult` alone. The two differ in
38073811
* the wrapper only, which is why the payload type is the same one.
38083812
*
3809-
* ⛔ NOT bound to `TriggerFlowResponse` (`@objectstack/spec/api`), which
3810-
* looks like this route's response type: its `data` declares
3811-
* `{ success, output?, error?, durationMs? }`, and the door also serves
3812-
* `status` / `runId` / `screen` (a paused run — see the row above),
3813-
* `code`, `successMessage` / `errorMessage` and `summary`. Measured
3814-
* against the real `AutomationEngine` in
3815-
* `analytics-automation-json-erasure.test.ts`. Binding the narrower
3816-
* schema would refuse the very reads this docblock tells callers to make.
3813+
* Deliberately bound to the CONTRACT, not to `TriggerFlowResponse`
3814+
* (`@objectstack/spec/api`). When #12104 wrote this annotation the
3815+
* schema was a stale projection — its `data` declared
3816+
* `{ success, output?, error?, durationMs? }` while the door also
3817+
* serves `status` / `runId` / `screen` (a paused run — see the row
3818+
* above), `code`, `successMessage` / `errorMessage` and `summary`
3819+
* (measured against the real `AutomationEngine` in
3820+
* `analytics-automation-json-erasure.test.ts`), so binding it would
3821+
* have refused the very reads this docblock tells callers to make.
3822+
* #13078 has since brought the schema to parity (pinned
3823+
* schema ≡ contract in `spec/api/automation-api.zod.test.ts`), but the
3824+
* binding stays on the producer's contract on purpose: the contract is
3825+
* what the route relays, and the schema is its transcription —
3826+
* annotating the source rather than the copy is what keeps this method
3827+
* immune to the transcription drifting again.
38173828
*/
38183829
trigger: async (triggerName: string, payload: any): Promise<BaseResponse & { data: AutomationResult }> => {
38193830
const route = this.getRoute('automation');

packages/client/src/return-type-precision.test.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -488,14 +488,19 @@ export async function returnTypePrecisionPins12104(): Promise<void> {
488488
// @ts-expect-error `queryDataset` is served bare by @objectstack/rest — there is no envelope
489489
void (await client.analytics.queryDataset({ selection: { measures: ['n'] } })).data;
490490

491-
// The two spec response types that LOOK like the right binding and are
492-
// narrower than the contract their route relays. Pinned at the binding so a
493-
// future sweep cannot "tidy" either annotation onto them: the keys below are
494-
// served by the real producers and neither schema declares them.
495-
// @ts-expect-error `TriggerFlowResponse.data` declares no `runId` — a paused run carries one
496-
void (undefined as unknown as TriggerFlowResponse).data.runId;
497-
// @ts-expect-error `TriggerFlowResponse.data` declares no `screen` — a screen-flow pause carries one
498-
void (undefined as unknown as TriggerFlowResponse).data.screen;
491+
// [#13078] RE-JUDGED, not re-spelled (the #6442 treatment): two
492+
// suppressions here used to pin that `TriggerFlowResponse.data` declared
493+
// neither `runId` nor `screen` — the near-miss trap that made binding the
494+
// schema a false narrowing. That premise is retired by design:
495+
// #13078 widened both stale schemas to parity with the contracts their
496+
// routes relay, so the old pins' suppressions would now be UNUSED
497+
// (TS2578) precisely because the defect they pinned is fixed. The new
498+
// truth is stronger and pinned as an equality: the schema's `data` IS the
499+
// producer contract. Narrow the schema again and this goes red — the same
500+
// guard, pointing in the direction that is now true. (The annotations
501+
// above still bind the CONTRACT on purpose: it is the source the routes
502+
// relay; the schema is its transcription.)
503+
expectTypeOf<TriggerFlowResponse['data']>().toEqualTypeOf<AutomationResult>();
499504
}
500505

501506
/**

packages/spec/api-surface/api.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
"AnalyticsProtocol (interface)",
3838
"AnalyticsQueryRequest (type)",
3939
"AnalyticsQueryRequestSchema (const)",
40+
"AnalyticsResultResponse (type)",
41+
"AnalyticsResultResponseParsed (type)",
4042
"AnalyticsResultResponseSchema (const)",
4143
"AnalyticsSqlResponse (type)",
4244
"AnalyticsSqlResponseParsed (type)",

packages/spec/docs-import-surface.baseline.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
"ai/AIModelConfig — no type export",
55
"ai/CodeContent — no type export",
66
"ai/ImageContent — no type export",
7-
"api/AnalyticsResultResponse — no type export",
87
"api/Discovery — no type export",
98
"api/HttpFindQueryParams — no type export",
109
"api/MetadataExportRequest — no type export",

packages/spec/export-origins/api.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
"AnalyticsProtocol": "src/api/protocol.zod.ts#AnalyticsProtocol (interface)",
3838
"AnalyticsQueryRequest": "src/api/analytics.zod.ts#AnalyticsQueryRequest (type)",
3939
"AnalyticsQueryRequestSchema": "src/api/analytics.zod.ts#AnalyticsQueryRequestSchema (const)",
40+
"AnalyticsResultResponse": "src/api/analytics.zod.ts#AnalyticsResultResponse (type)",
41+
"AnalyticsResultResponseParsed": "src/api/analytics.zod.ts#AnalyticsResultResponseParsed (type)",
4042
"AnalyticsResultResponseSchema": "src/api/analytics.zod.ts#AnalyticsResultResponseSchema (const)",
4143
"AnalyticsSqlResponse": "src/api/analytics.zod.ts#AnalyticsSqlResponse (type)",
4244
"AnalyticsSqlResponseParsed": "src/api/analytics.zod.ts#AnalyticsSqlResponseParsed (type)",

0 commit comments

Comments
 (0)