Skip to content

Commit fb8e018

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15591-seed-apply-decorated-envelope
2 parents 73cdcb3 + d5d8d50 commit fb8e018

386 files changed

Lines changed: 13957 additions & 909 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
A dataset measure's `fields[].type` stops contradicting the value beside it: a `min`/`max` over a temporal field is described as `time`, not `number` (#15768)
7+
8+
`POST /api/v1/analytics/dataset/query` described **every** measure column as `type: "number"`, including a `min`/`max` over a `date` / `datetime` / `time` field whose value in the same response is an ISO instant. Measured on a real boot (`@objectstack/cli` 17.3.0, SQLite dev datasource):
9+
10+
```json
11+
{"rows":[{"oldest_last_update_at":"2026-07-04T07:00:00.000Z"}],
12+
"fields":[{"name":"oldest_last_update_at","type":"number","label":"Oldest touch","format":"relative"}]}
13+
```
14+
15+
`min` and `max` return a value **of the aggregated field's own type**, so that column carries an instant and the metadata denied it — which is enough on its own to keep a formatter that branches on the declared type from ever reaching a temporal branch.
16+
17+
What changed:
18+
19+
- **The measure column's type is resolved from the authored measure plus the source field's declared type**, in `AnalyticsService.queryDataset`'s ADR-0021 result-column enrichment — the same block that already resolves `label` / `format` / `currency` / `percentScale`, and the one seam every producer of the shape passes through on the way to the route, which relays that method's return verbatim. The rule itself is `measureResultType` in the new `measure-result-type.ts`, so the per-aggregate verdict has one home instead of four copies.
20+
- **The corrected spelling is `time`**, the `DimensionType` word a temporal DIMENSION column in the same response has always carried. A second temporal word in one wire position would have left every existing consumer branch unreached.
21+
- **Only `min` and `max` move.** `count` and `count_distinct` are numeric however temporal the column they read is; `sum` / `avg` over a temporal column are refused by no layer and answered by the backend (an epoch mean on SQLite, an error on Postgres), so there is no single value for a type to describe and none is invented; a derived measure is numeric by construction, because `computeDerived` coerces its operands with `Number()`. Row values are untouched on every path.
22+
- **Tiered "cannot answer, do not block".** A host with no source-field metadata wired, and a measure over a relationship PATH (which the source-field lookup resolves against the base object and therefore cannot answer), both leave the column exactly as the query layer produced it.
23+
24+
`AnalyticsResult.fields[].type` and the `AnalyticsResultResponse` schema now state the vocabulary this position speaks and what each aggregate answers; neither declaration widens — the wire type was, and remains, a string.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": patch
4+
---
5+
6+
feat(spec)!: the twelve `api/` duration keys carry their unit in the key name (#15677, ruling B on #14478)
7+
8+
<!-- adr-0087: registered api-endpoint-cache-ttl-to-cache-ttl-seconds, api-error-retry-after-unit-in-key, api-runtime-config-durations-unit-in-key, device-request-response-interval-unit-in-key, rest-api-plugin-durations-unit-in-key, websocket-durations-unit-in-key -->
9+
10+
**BREAKING** — twelve published `api/` duration keys are renamed and tombstoned.
11+
Shipped as `minor` under the repo's launch-window convention for breaking
12+
changes; the hand-migration prescriptions are registered under protocol major
13+
18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, 「同意」).
14+
15+
`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit
16+
in the key NAME, never only in its `.describe()` prose, and grandfathers no
17+
existing offender. Stack card 1/6 (#15676) landed the rule's two structural
18+
exemptions; this card clears the `api/` directory against it. Measured with the
19+
gate itself: `src/api/**` goes from 12 offenders to **0**, and the whole-tree
20+
count falls **48 → 36**.
21+
22+
## FROM → TO
23+
24+
| key | replacement | unit |
25+
|:--|:--|:--|
26+
| `ApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds |
27+
| `DataLoaderConfig.cacheTtl` | `cacheTtlSeconds` | seconds |
28+
| `DeviceRequestResponse.interval` | `intervalSeconds` | seconds |
29+
| `EnhancedApiError.retryAfter` | `retryAfterSeconds` | seconds |
30+
| `RestApiEndpoint.timeout` | `timeoutMs` | milliseconds |
31+
| `RestApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds |
32+
| `RestApiPluginConfig.performance.defaultCacheTtl` | `defaultCacheTtlSeconds` | seconds |
33+
| `RouteDefinition.timeout` | `timeoutMs` | milliseconds |
34+
| `WebSocketConfig.reconnectInterval` | `reconnectIntervalMs` | milliseconds |
35+
| `WebSocketConfig.pingInterval` | `pingIntervalMs` | milliseconds |
36+
| `WebSocketConfig.timeout` | `timeoutMs` | milliseconds |
37+
| `WebSocketServerConfig.heartbeatInterval` | `heartbeatIntervalMs` | milliseconds |
38+
39+
**Every value is unchanged** — only key names move. Every old spelling is a
40+
`retiredKey()` tombstone, so it fails `tsc` at the authoring site (input type
41+
`never`) and fails the parse with the rename prescription rather than a bare
42+
unrecognized-key error.
43+
44+
## ⚠️ `ApiError.retryAfter` — the wire envelope, and what it does NOT touch
45+
46+
Ruling B put this key explicitly in scope with its own BREAKING note: the
47+
runtime-emitted measurements are read by humans and agents even though nobody
48+
authors them. A consumer meets two retry-after values on one 429 — this
49+
ADR-0112 envelope field, always delta-seconds, and the HTTP `Retry-After`
50+
header, which per RFC 9110 §10.2.3 may carry delta-seconds **or** an HTTP-date.
51+
Spelled identically they read as one value in two places.
52+
53+
**The HTTP `Retry-After` response header is a separate, unchanged surface.** Its
54+
name is fixed outside this repo and nothing here touches it. Do not "fix" the
55+
header to match the envelope, and do not read a surviving `retry-after` in
56+
transport code as leftover work.
57+
58+
## Dispositions — one D2 conversion, five semantic entries
59+
60+
Justified per key rather than defaulted. **`ApiEndpoint.cacheTtl` is the only
61+
one of the twelve that gets an ADR-0087 D2 conversion**
62+
(`api-endpoint-cache-ttl-to-cache-ttl-seconds`), because `apis:` is a stack
63+
collection (`apis: z.array(ApiEndpointSchema)`) and `api` is a registered
64+
metadata kind stored as a row, so the conversion chain has a seam that sees it.
65+
`os migrate meta --from 17` lists the mechanical edits.
66+
67+
The other eleven are wire payloads and construction arguments — a device-flow
68+
response body, an error envelope, REST-plugin route registration, a batch-loader
69+
config, a router registration, WebSocket client/server configuration. None is
70+
ever a stack collection member or a `sys_metadata` row, so no conversion seam
71+
runs on them and each carries a **semantic** entry instead: this is the
72+
disposition `api/RestApiEndpoint:handlerStatus` already holds on one of these
73+
very shapes, and what ruling B prescribes for a runtime-emitted key.
74+
75+
## `DeviceRequestResponse.interval` is a rename, not an external-vocabulary mirror
76+
77+
Attributed to RFC 8628 by the campaign card; the attribution fails against the
78+
schema's own evidence. `DeviceRequestResponseSchema` does not mirror RFC 8628 as
79+
a set — `code` is not `device_code`, `verificationUrl` is not
80+
`verification_uri`, `expiresAt` is not `expires_in` (a different name *and* a
81+
different type, an ISO-8601 instant where the RFC carries a relative lifetime).
82+
A schema that already renames every RFC field it carries into house style cannot
83+
claim the standard fixes the one name it left bare. Renamed rather than marked
84+
deliberately: a wrongly marked key is exempted permanently and silently, while a
85+
wrongly renamed one is visible.
86+
87+
## Readers moved in the same PR, at the same magnitude
88+
89+
`@objectstack/runtime`'s policy chain (`computeCacheControl` now reads
90+
`endpoint.cacheTtlSeconds`), the publish gate's issue path
91+
(`apis.N.cacheTtlSeconds`), the built-in REST route tables, the showcase
92+
example, dogfood fixtures, `liveness/api.json` (renamed row plus a `dead`
93+
tombstone row) and the `objectstack-api` skill. The `ApiEndpoint` alias table is
94+
retargeted onto the live key — an alias must point at a key the schema really
95+
accepts, and `cacheTtl` now accepts nothing.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/driver-turso": patch
4+
"@objectstack/driver-sql": patch
5+
"@objectstack/service-analytics": patch
6+
---
7+
8+
Correct the documented reason for rejecting `CAST(col AS BLOB) LIKE ?` as a portable case-exact construct.
9+
10+
Four headers stated, as a universal fact about SQLite, that the construct "was measured to return NOTHING". That is not a property of SQLite: whether `LIKE` is false for a BLOB operand is fixed when SQLite is compiled, by `SQLITE_LIKE_DOESNT_MATCH_BLOBS`, and the two SQLite builds this project ships disagree about it. Measured over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` compiled to that construct returns `[]` on better-sqlite3 13.0.3 (SQLite 3.53.4, flag compiled in) and `['1','2']` on sql.js 1.14.1 (SQLite 3.49.1, flag absent) — the latter being exactly the ASCII case-folding defect the construct was being considered to avoid.
11+
12+
No behaviour changes and no conclusion changes: all four sites still reject the construct and still choose `GLOB`. The rejection is now stated in a form that does not depend on any particular return value — a construct whose meaning is decided by an upstream compile flag cannot carry a read scope, because it means two different things on the two builds shipped here. Two supporting readings are recorded alongside it: `typeof CAST(name AS BLOB)` is `'blob'` on both builds, so the CAST is not the part that differs, and `GLOB` answers identically on both.
13+
14+
Documentation only. `@objectstack/spec` and `@objectstack/driver-turso` ship the corrected text in their published type declarations (and `spec` also publishes the corrected source file directly, via its `src/**/*.zod.ts` entry); for `@objectstack/driver-sql` and `@objectstack/service-analytics` the change reaches published output only through sourcemaps.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `explain` names the renamed `dashboard.refreshIntervalSeconds` (#14478)
6+
7+
The dashboard key catalogue `os explain` prints lists
8+
`refreshIntervalSeconds` instead of `refreshInterval`, following the
9+
`@objectstack/spec` rename of the authored key (the unit now lives in the key
10+
name). Same key, same seconds; no other command output and no public surface of
11+
this package changes.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/client": patch
3+
---
4+
5+
The README's analytics and automation examples read the resolved payload.
6+
7+
`client.analytics.query` / `analytics.meta` and `client.automation.trigger` stopped handing back the dispatcher's `{ success, data }` envelope in 17.0.0: each resolves to the payload itself. The README's namespace tour still showed all three as bare `await` calls with nothing reading the resolved value, so the package's own front page taught nothing about which shape comes back — neither wrong nor useful. Each of the three now assigns its result and reads one member of it: `report.rows` / `report.fields[0].name` (`AnalyticsResult`), `cubes[0].name` (the bare `CubeMeta[]`), `run.status` (`AutomationResult`) — the members those contracts actually declare, read off the payload rather than off a `data` wrapper.
8+
9+
No behaviour changes; this is the README that ships inside the package. The docs site's Client SDK and Data API pages take the same treatment in the same PR.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: the last seven `data/` · `ui/` · `ai/` · `integration/` duration keys carry their unit in the key name (#15680, ruling B on #14478)
6+
7+
<!-- adr-0087: registered dashboard-refresh-interval-to-refresh-interval-seconds, connector-health-and-trigger-durations-unit-in-key, memory-persistence-auto-save-interval-to-ms, turso-config-timeout-to-timeout-ms, ai-conversation-analytics-duration-unit-in-key, data-nosql-query-options-timeout-unit-in-key -->
8+
9+
**BREAKING** — eight published duration keys are renamed and tombstoned. Shipped
10+
as `minor` under the repo's launch-window convention for breaking changes; the
11+
hand-migration prescriptions are registered under protocol major 18. Maintainer
12+
ruling B on #14478 (2026-09-02, decision batch #43, 「同意」).
13+
14+
`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit in
15+
the key NAME, never only in its `.describe()` prose, and grandfathers no existing
16+
offender. Card 1/6 (#15676) landed the rule's two structural exemptions, card 2/6
17+
(#15677) cleared `api/`, card 3/6 (#15678) cleared `kernel/` and card 4/6
18+
(#15679) cleared `system/`. This card clears the remainder, and is the first
19+
where the gate itself reads **`zero offenders`** and exits `0`.
20+
21+
⚠️ That is green **for the gate's currently declared population**
22+
(`packages/spec/src/**`), not for the epic. Card 6/6 widens the population and has
23+
already measured an offender outside this subtree, so the gate is expected to go
24+
red again by design. This changeset does not claim #14478 is finished.
25+
26+
## FROM → TO
27+
28+
| key | replacement | unit |
29+
|:--|:--|:--|
30+
| `dashboard.refreshInterval` | `refreshIntervalSeconds` | seconds |
31+
| `CircuitBreakerConfig.monitoringWindow` | `monitoringWindowMs` | milliseconds |
32+
| `ConnectorTrigger.interval` | `intervalSeconds` | seconds |
33+
| `FilePersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds |
34+
| `AutoPersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds |
35+
| `TursoConfig.timeout` | `timeoutMs` | milliseconds |
36+
| `NoSQLQueryOptions.timeout` | `timeoutMs` | milliseconds |
37+
| `ConversationAnalytics.duration` | `durationSeconds` | seconds |
38+
39+
**Every value is unchanged** — only key names move. The two keys that carried a
40+
default keep it (`CircuitBreakerConfig.monitoringWindowMs` still defaults to
41+
60000, `FilePersistenceConfig.autoSaveIntervalMs` to 2000); the other six declare
42+
none. Bounds move with their keys, so `autoSaveIntervalMs` still refuses anything
43+
under 100 on both persistence arms, `NoSQLQueryOptions.timeoutMs` and
44+
`TursoConfig.timeoutMs` still refuse a zero or negative integer, and
45+
`ConversationAnalytics.durationSeconds` still refuses a negative length. Every old
46+
spelling is a `retiredKey()` tombstone, so it fails `tsc` at the authoring site
47+
(input type `never`) and fails the parse with the rename prescription rather than
48+
a bare unrecognized-key error.
49+
50+
`dashboard`'s three rename-hint aliases — `refresh`, `autoRefresh`, `pollInterval`
51+
— were repointed to `refreshIntervalSeconds` in the same edit. A hint left naming
52+
the tombstone would have prescribed a key the shape refuses, which is the one
53+
failure this rename could have introduced silently; a pin asserts all three.
54+
55+
## ⚠️ `dashboard.refreshInterval` crosses a repository boundary
56+
57+
This is the only rename in the whole stack whose consumer is in **another
58+
repository**, so its reader could not move in this PR the way every other reader
59+
in this card did. objectui's dashboard renderer reads the key, multiplies by
60+
1000 to drive a `setInterval`, and republishes it as an authoring input the
61+
console offers. Those sites move in a follow-up objectui card, sequenced behind
62+
a release that actually ships this rename.
63+
64+
Until that lands the renderer sees an absent key and simply does not start its
65+
refresh timer — a dashboard still renders, and still refreshes when the user
66+
asks. The ADR-0087 conversion in this changeset is what keeps stored dashboards
67+
and `os migrate meta` correct in the meantime.
68+
69+
## ⚠️ An eighth key moves that the gate did not list
70+
71+
`AutoPersistenceConfig.autoSaveInterval` is not a gate offender: its `.describe()`
72+
named no unit at all, and the predicate judges prose against name.
73+
74+
It moves anyway because it is not a second key. `persistence: { type: 'auto' }`
75+
resolves to the same Node.js file adapter as `type: 'file'`, and this value is
76+
forwarded to the same `FileSystemPersistenceAdapter` field, in the same
77+
milliseconds, under the same `min(100)` bound. Renaming one arm and not the other
78+
would have left one value with two spellings across sibling arms of one union,
79+
and the driver reading both — the consumer-side dialect Prime Directive #12
80+
forbids. Its describe now names the unit too, and a pin asserts the refusal on
81+
the arm the gate never listed, so a later reader cannot "restore" the bare
82+
spelling as an over-application of the rule.
83+
84+
## Dispositions — four D2 conversions, two semantic entries
85+
86+
Judged per key from `stack.zod.ts`'s collection roots rather than defaulted, and
87+
unlike card 4/6 this card's answer is split.
88+
89+
**D2 conversions** (six keys). `dashboards:`, `connectors:` and `datasources:`
90+
are each a stack collection whose members are stored whole as `sys_metadata`
91+
rows, so the conversion chain has a seam that sees them:
92+
`dashboard-refresh-interval-to-refresh-interval-seconds`,
93+
`connector-health-and-trigger-durations-unit-in-key` (both connector keys in one
94+
pass, emitting separately),
95+
`memory-persistence-auto-save-interval-to-ms` (both persistence arms) and
96+
`turso-config-timeout-to-timeout-ms`. The two datasource conversions are
97+
driver-aware for the reason `datasource-config-driver-key-aliases` records: a
98+
bare `config.timeout` under another driver is that driver's own key and must not
99+
be touched.
100+
101+
**Semantic entries** (two keys). `ConversationAnalytics` is computed at runtime
102+
and handed to a consumer, and `NoSQLQueryOptions` is a per-call driver argument
103+
reached only through `AggregationPipeline.options`. Neither is a stack collection
104+
member or a stored row, so the chain has no seam — the disposition every
105+
runtime-emitted measurement in this stack has taken.
106+
107+
All eight are registered by exact key in `RETIRED_KEYS_BY_MAJOR`.
108+
109+
## A retirement tombstone is no longer read as a secret
110+
111+
`refusedCredentialKeys` derives a driver's refused inline credentials by finding
112+
`z.never()` keys in its config contract. A `retiredKey()` tombstone is also a
113+
`z.never()`, and until this card no driver contract carried one — so "never ⇒
114+
credential" held by accident of population rather than by construction. The first
115+
tombstone to arrive (`TursoConfig.timeout`) made the derivation answer that a
116+
millisecond budget was a secret: it was redacted off the datasource read path and
117+
dragged a non-credential name into the fallback list every unrecognised driver is
118+
scrubbed by.
119+
120+
The derivation now skips keys carrying the `[REMOVED] ` prefix `retiredKey()`
121+
itself stamps. The exclusion is deliberately **negative** — skip declared
122+
tombstones — rather than positive (keep only keys marked `format: 'password'`),
123+
even though every credential slot in every builtin contract does carry that
124+
marker today: under-redacting is the dangerous direction, so a future credential
125+
key whose author forgets the marker is still scrubbed, and only a key that has
126+
explicitly declared itself retired may drop out. Both directions are pinned.
127+
128+
## Keys deliberately left alone
129+
130+
`TursoConfig.sync.intervalSeconds` and `CircuitBreakerConfig.resetTimeoutMs`
131+
already carried their unit — they are the same-shape neighbours that made the
132+
bare `timeout` and `monitoringWindow` collisions visible, and pins assert they
133+
did not move. `NoSQLQueryOptions.batchSize` is a COUNT of documents and every
134+
number on `ConversationAnalytics` other than the duration is a count of messages,
135+
tokens or events: a count has no unit to carry. The turso schema shipped by
136+
`@objectstack/driver-turso` is a separate declaration outside this gate's
137+
declared population and is not touched here; card 6/6 owns it, so the two
138+
declarations disagree by design until that lands.

0 commit comments

Comments
 (0)